Compare commits

...

210 Commits

Author SHA1 Message Date
Nate Butler
4dc28572af clipppypyyyyyyyyy 2025-04-08 15:46:17 -06:00
Nate Butler
f416c2577c Merge 2025-04-08 15:41:45 -06:00
Nate Butler
618db711b1 Update Cargo.toml 2025-04-08 15:10:12 -06:00
Nate Butler
690dd993b1 wip 2025-04-08 15:08:34 -06:00
Joseph T. Lyons
763cc6dba3 Tell the model not to act on TODO type comments (#28358)
Release Notes:

- Adjusted system prompt to direct it to never act on TODO-type comments
it encounters, unless the user directly asked it to do so or they relate
to the current task at hand.
2025-04-08 21:00:02 +00:00
Piotr Osiewicz
0b75c13034 chore: Replace as_any functions with trait upcasting (#28221)
Closes #ISSUE

Release Notes:

- N/A
2025-04-08 22:16:27 +02:00
Ben Kunkle
38ec45008c project: Workaround invalid code action edits from pyright (#28354)
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Piotr Osiewicz <piotr@zed.dev>

fixes issue where:

In a two line python file like so
```

Path()
```

If the user asks for code actions on `Path` and they select (`From
pathlib import path`)
the result they get is
```

Pathfrom pathlib import Path


Path()
```
Instead of 

```

from pathlib import Path



Path()
```

This is due to a non-lsp-spec-compliant response from pyright below

```json
{"jsonrpc":"2.0","id":40,"result":[{"title":"from pathlib import Path","edit":{"changes":{"file:///Users/neb/Zed/example-project/pyright-project/main.py":[{"range":{"start":{"line":2,"character":0},"end":{"line":2,"character":4}},"newText":"Path"},{"range":{"start":{"line":2,"character":0},"end":{"line":2,"character":0}},"newText":"from pathlib import Path\n\n\n"}]}},"kind":"quickfix"}]}
```

Release Notes:

- Fixed an issue when using auto-import code actions provided by pyright
(or basedpyright) where the import would be jumbled with the scoped
import resulting in an invalid result

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
2025-04-08 20:13:44 +00:00
Antonio Scandurra
97641c3298 Use tree-sitter when returning symbols to the model for a given file (#28352)
This also increases the threshold for when we return an outline during
`read_file`.

Release Notes:

- Fixed an issue that caused the agent to fail reading large files if
the LSP hadn't started yet.
2025-04-08 16:11:05 -04:00
Joseph T. Lyons
ca8f6e8a3f Tell the model not to remove tests (#28349)
Release Notes:

- Adjusted system prompt to direct it to never remove tests as a way to
have the test suite pass, unless the user directly asks for test
removal.
2025-04-08 19:26:43 +00:00
Piotr Osiewicz
db53da49e1 debugger: Respect initialize_args from user profiles (#28347)
Closes #ISSUE

Release Notes:

- N/A

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
2025-04-08 21:15:05 +02:00
Peter Tripp
df94dcdea6 ci: Only run workspace_hack when tests run (#28346)
Skip `workspace_hack` for PRs that don't trigger tests (docs-only,
.github issue templates, etc).

Release Notes:

- N/A
2025-04-08 18:55:54 +00:00
Richard Feldman
1c85901440 Tell the model not to create .bak files (#28244)
Release Notes:

- Adjusted system prompt to avoid having the agent create backup files
unnecessarily.
2025-04-08 18:45:35 +00:00
Peter Tripp
9fb77ad176 Refine GitHub Issue templates (#28345)
Make various improvements to our github issue templates.

- Adjust line lengths to not wrap in constrained new issue view (85
cols) not just full screen view (95 columns)
- Remove reference to drag/drop logs to upload (recently multiple issues
with dead upload links)
- Cleanup list view

Release Notes:

- N/A
2025-04-08 14:41:55 -04:00
Michael Sloan
feafad2f9d Improve comments on shader code for dashed borders (#28341)
Improvements from going over the code with @as-cii 

Release Notes:

- N/A
2025-04-08 18:08:22 +00:00
Piotr Osiewicz
86ef00054b pylsp: Upgrade existing installation if possible (#28338)
Closes #ISSUE

Release Notes:

- Zed-managed pylsp installations will now correctly upgrade themselves
2025-04-08 20:01:09 +02:00
Conrad Irwin
9e8afa8daa Fix local task dropped on the wrong thread (#28290)
Release Notes:

- Fixed a panic during shutdown of the remote server
2025-04-08 11:54:51 -06:00
Danilo Leal
698cdc4d1a agent: Display "generating" label in the active thread (#28297)
Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-04-08 14:31:32 -03:00
Agus Zubiaga
85c5d8af3a agent: Truncate bash tool output (#28291)
The bash tool will now truncate its output to 8192 bytes (or the last
newline before that).

We also added a global limit for any tool that produces a clearly large
output that wouldn't fit the context window.

Release Notes:

- agent: Truncate bash tool output

---------

Co-authored-by: Michael Sloan <mgsloan@gmail.com>
2025-04-08 16:55:35 +00:00
Anthony Eid
1774cad933 debugger: Add close button and coloring to debug panel session's menu (#28310)
This PR adds colors to debug panel's session menu that indicate the
state of each respective session. It also adds a close button to each
entry.

green - running
yellow - stopped
red - terminated/ended 


Release Notes:

- N/A
2025-04-08 16:35:33 +00:00
Conrad Irwin
ee7b1ec7f2 Fix deafening new participants (#28330)
Release Notes:

- Fixed an issue where new participants were not muted when the room was
deafened
2025-04-08 16:01:27 +00:00
Bennet Bo Fenner
14b43d573c agent: Navigate to line when clicking on filepath in markdown codeblock (#28329)
Release Notes:

- N/A
2025-04-08 15:41:34 +00:00
Max Brunsfeld
d39e1e03b8 Demote buffer-diff to a dev dependency of collab (#28295)
This will speed up our collab deployments a little

Release Notes:

- N/A
2025-04-08 08:36:44 -07:00
Peter Tripp
9e504a1ed9 git: Fix logging FromUtf8Error when diffing (#28276)
If you attempt to load a git diff which includes a non utf-8 file,
previously
(1) the entire contents of the file was logged as ordinals and
(2) a second spurious error was logged

```
2025-04-07T16:21:28.392845-04:00 [ERROR] FromUtf8Error { bytes: [0, 1, 0, 0, 0, 19, 1, 0, 0, 4, 0, 48, 68, 83, 73, 71, 0, 0, 0, 1, 0, 2, 241, 204, 0, 0, 0, 8, 71, 68, 69, 70, 164, 172, 164, ...

[2025-04-07T17:12:16-04:00 ERROR git::repository] Error loading index text: invalid utf-8 sequence of 1 bytes from index 35
```

Having 1MB binary file in a commit would generate ~3MB-5MB of log
output.

Discovered while investigating
https://github.com/zed-industries/zed/issues/28241

Release Notes:

- git: Fixed an issue where non-UTF8 files in a git diff would generate
log spam.
2025-04-08 11:28:34 -04:00
Conrad Irwin
ca4cc4764b Upgrade async-tungstenite to tokio (#26193)
We're seeing panics caused by a buggy implementation of AsyncWrite
that is being passed to rustls: 

https://github.com/rustls/rustls/issues/2316#issuecomment-2662838186

One hypothesis was that we're using (comparatively) non-standard async
tools for connecting over websockets; so this attempts to make us be
(comparitvely) more standard.

Release Notes:

- N/A
2025-04-08 09:17:08 -06:00
5brian
ea33d78ae4 vim: Fix visual line yank on newline char (#28005)
Problem:
When yanking in visual line on the newline char, the next line gets
yanked as well:


https://github.com/user-attachments/assets/40f332dd-19f5-445f-a30f-39d50167c46f

Changes:
Similar to visual delete, exclude the newline char from the selection in
line mode.

Release Notes:

- vim: Fixed visual line yank while on the newline character yanking
following line
2025-04-08 09:15:34 -06:00
5brian
e36a2f2739 vim: Add indent-wise motions (#28044)
Taken from:
https://github.com/jeetsukumaran/vim-indentwise?tab=readme-ov-file#movements-by-relative-indent-depth



> [- : Move to previous line of lesser indent than the current line.
> [+ : Move to previous line of greater indent than the current line.
> [= : Move to previous line of same indent as the current line that is
separated from the current line by lines of different indents.
> ]- : Move to next line of lesser indent than the current line.
> ]+ : Move to next line of greater indent than the current line.
> ]= : Move to next line of same indent as the current line that is
separated from the current line by lines of different indents.



Release Notes:

- vim: Added indent-wise motions `] -/+/=`
2025-04-08 09:07:37 -06:00
jneem
cbd9b4cc39 Switch back to the default mode after paste (#28304)
Now that the flaky tests are disabled, this should work...

Release Notes:

- Fixed vim paste action to switch back to the configured default mode.
2025-04-08 09:03:55 -06:00
James Falade
64f8b1e739 docs: Add missing comma in the Tasks page (#28324)
Added a missing command in the same tasks.json file

Release Notes:

- N/A
2025-04-08 15:03:42 +00:00
5brian
4f936d8100 vim: Fix exchange showing ccx in pending keys (#28303)
|Before|After|
|--|--|

|![image](https://github.com/user-attachments/assets/cac97981-518e-46d2-8540-a22496bc948e)|![image](https://github.com/user-attachments/assets/5158b17f-d14e-42a2-8a94-ad98d1b1c33c)|

Changes:
Add vim exchange to the clear stack

Release Notes:

- N/A
2025-04-08 09:03:24 -06:00
Bennet Bo Fenner
97abf21a28 agent: Add support for Google Gemini 2.5 preview (#28326)
Adds support for the new `gemini-2.5-pro-preview-03-25`

Release Notes:

- Added support for `gemini-2.5-pro-preview-03-25` in the assistant
2025-04-08 15:00:23 +00:00
Piotr Osiewicz
5fb1411e4d debugger: Add scrollbars, fix papercuts with completions menu and jumps (#28321)
Closes #ISSUE

Release Notes:

- N/A
2025-04-08 16:55:18 +02:00
0x2CA
b04f7a4c7c vim: Fix visual object expands (#28301)
Closes #28198

Release Notes:

- Fixed visual object expands
2025-04-08 08:51:41 -06:00
Marshall Bowers
61b7a05792 language_models: Allow overriding Zed completions URL via environment variable (#28323)
This PR adds support for overriding the Zed completions URL via the
`ZED_COMPLETIONS_URL` environment variable.

Release Notes:

- N/A
2025-04-08 14:46:15 +00:00
Peter Tripp
cc15598e09 keymap: Document conflicting macos ctrl-shift-space shortcut (#28325)
Closes https://github.com/zed-industries/zed/issues/26261

Release Notes:

- N/A
2025-04-08 14:46:03 +00:00
Smit Barmase
7ee9109ade project_panel: Do not allow creating empty file/dir or file/dir with only whitespaces (#28240)
- Do not allow creating empty file or empty directory.
- Do not allow creating file or directory with just whitespace.
- Show error only in case whitespace.

<img width="352" alt="image"
src="https://github.com/user-attachments/assets/f6040332-59a6-4d09-bf07-2b4b1b8b9e03"
/>

Release Notes:

- N/A
2025-04-08 18:00:01 +05:30
Piotr Osiewicz
c21fdd212b python: Bump PET version (#28319)
Speculative fix for #27518


Release Notes:

- N/A
2025-04-08 11:04:57 +00:00
tidely
a28929592e gpui: Depend on workspace image crate (#28313)
Make gpui depend on the image crate on the workspace level

Release Notes:

- N/A
2025-04-08 12:21:24 +02:00
Smit Barmase
3b787e85a4 Fix scrolling too fast on selection for editor and terminal (#28309) 2025-04-08 12:16:18 +05:30
Kirill Bulatov
1264e7a200 Properly store editor restoration data (#28296)
We cannot compare versions and anchors between different `Buffer`s with
different `BufferId`s.

Release Notes:

- Fixed Zed panicking on editor reopen

Co-authored-by: Conrad Irwin <conrad@zed.dev>
2025-04-08 01:25:43 +00:00
Richard Feldman
bfe08e449f Tell the system prompt not to write incomplete code (#28245)
Sometimes agents do this. I've had some success responding by telling it
not to do this, so trying out having it in the system prompt.

Release Notes:

- Adjusted the system prompt to avoid incomplete code generation.
2025-04-07 20:59:52 -04:00
Piotr Osiewicz
df3c7a73b5 debugger: Pick best candidate binary for debugging cargo-located tasks (#28289)
Closes #ISSUE

Release Notes:

- N/A
2025-04-08 02:30:21 +02:00
Max Brunsfeld
0dc3dffe38 Use insert_id as partition key for crash events (#28293)
Release Notes:

- N/A
2025-04-07 17:24:31 -07:00
Bennet Bo Fenner
b306a0221b agent: Add headers for code blocks (#28253)
<img width="639" alt="image"
src="https://github.com/user-attachments/assets/1fd51387-cbdc-474d-b1a3-3d0201f3735a"
/>


Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-04-07 23:56:24 +00:00
Max Brunsfeld
d385a60ed1 Fix phrasing of crash/panic event names 2025-04-07 16:31:29 -07:00
Danilo Leal
b27922129c agent: Refine individual file item design in the edit disclosure (#28283)
<img
src="https://github.com/user-attachments/assets/f1ad0598-d864-407f-8b81-6ca29e2ffae3"
width="650"/>

Release Notes:

- N/A
2025-04-07 19:58:49 -03:00
Max Brunsfeld
6220b86f94 Write panics and crashes to snowflake (#28284)
This will let us create a better crashes dashboard, using Hex.

Release Notes:

- N/A
2025-04-07 15:50:16 -07:00
Conrad Irwin
448db20eaa Fix bad unicode calculations in do_completion (#28259)
Co-authored-by: João Marcos <marcospb19@hotmail.com>

Release Notes:

- Fixed a panic with completions around non-ASCII code

---------

Co-authored-by: João Marcos <marcospb19@hotmail.com>
2025-04-07 22:45:29 +00:00
renovate[bot]
e4a6943c76 Update Rust crate tokio to v1.44.2 [SECURITY] (#28277)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [tokio](https://tokio.rs)
([source](https://redirect.github.com/tokio-rs/tokio)) | dependencies |
patch | `1.44.1` -> `1.44.2` |
| [tokio](https://tokio.rs)
([source](https://redirect.github.com/tokio-rs/tokio)) |
workspace.dependencies | patch | `1.44.1` -> `1.44.2` |

### GitHub Vulnerability Alerts

####
[GHSA-rr8g-9fpq-6wmg](https://redirect.github.com/tokio-rs/tokio/pull/7232)

The broadcast channel internally calls `clone` on the stored value when
receiving it, and only requires `T:Send`. This means that using the
broadcast channel with values that are `Send` but not `Sync` can trigger
unsoundness if the `clone` implementation makes use of the value being
`!Sync`.

Thank you to Austin Bonander for finding and reporting this issue.

---

### Release Notes

<details>
<summary>tokio-rs/tokio (tokio)</summary>

###
[`v1.44.2`](https://redirect.github.com/tokio-rs/tokio/releases/tag/tokio-1.44.2):
Tokio v1.44.2

[Compare
Source](https://redirect.github.com/tokio-rs/tokio/compare/tokio-1.44.1...tokio-1.44.2)

This release fixes a soundness issue in the broadcast channel. The
channel
accepts values that are `Send` but `!Sync`. Previously, the channel
called
`clone()` on these values without synchronizing. This release fixes the
channel
by synchronizing calls to `.clone()` (Thanks Austin Bonander for finding
and
reporting the issue).

##### Fixed

- sync: synchronize `clone()` call in broadcast channel ([#&#8203;7232])

[#&#8203;7232]: https://redirect.github.com/tokio-rs/tokio/pull/7232

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "" in timezone America/New_York,
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these
updates again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOS4yMjcuMyIsInVwZGF0ZWRJblZlciI6IjM5LjIyNy4zIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 18:38:32 -04:00
Conrad Irwin
f03efeda73 Try to identify dSYMs by UUID not channel (#28268)
This should make it possible to more reliably symbolicate crash reports
from nightly, and from users with pending auto-updates.


Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
2025-04-07 16:08:38 -06:00
Anthony Eid
862d0c07ca debugger: Fix gdb adapter and logger (#28280)
There were two bugs that caused the gdb adapter not to work properly,
one on our end and one their end.

The bug on our end was sending `stopOnEntry: null` in our launch request
when stop on entry had no value. I fixed that bug across all dap
adapters

The other bug had to do with python's "great" type system and how we
serialized our unit structs to json; mainly,
`ConfigurationDoneArguments` and `ThreadsArguments`. Gdb seems to follow
a pattern for handling requests where they pass `**args` to a function,
this errors out when the equivalent json is `"arguments": null`.

```py
@capability("supportsConfigurationDoneRequest")
@request("configurationDone", on_dap_thread=True)
def config_done(**args): ### BUG!!
    ...
```

Release Notes:

- N/A
2025-04-07 22:02:13 +00:00
Danilo Leal
56ed5dcc89 agent: Add the history button back in the toolbar and make it a toggle (#28275)
Release Notes:

- agent: The history view is now more easily accessible via the icon
button in the Agent Panel toolbar.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-04-07 18:58:49 -03:00
Conrad Irwin
b3f47dc5e0 Temporarily disable helix tests (#28279)
Not sure why, but recent changes to helix have made these flakey.

We can re-enable when we understand.

Release Notes:

- N/A
2025-04-07 21:50:35 +00:00
Marshall Bowers
fe1ae1860e agent: Copy text as Markdown (#28272)
Release Notes:

- agent: Copying text in the Agent Panel will now copy it as Markdown.

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-04-07 17:42:11 -04:00
Bennet Bo Fenner
c165729b3f agent: Add a way to go back to thread from settings/history (#28273)
Release Notes:

- N/A
2025-04-07 21:25:40 +00:00
Piotr Osiewicz
22b937f27f Debugger UI: Dynamic session contents (#28033)
Closes #ISSUE

Release Notes:

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

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Anthony <anthony@zed.dev>
2025-04-07 23:22:09 +02:00
Danilo Leal
fdaf2a27bf agent: Adjust the thread generation design (#28193)
This PR simplifies the button to send a new message as well as the
"generation" display design.

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-04-07 18:09:38 -03:00
Marshall Bowers
0414908c4a markdown: Move open_url to the MarkdownElement as on_url_click (#28269)
Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-04-07 20:43:00 +00:00
5brian
d3abc61728 breadcrumbs: Update multibuffer to match singleton (#28267)
Before:


https://github.com/user-attachments/assets/a2c8fe84-14f6-4cda-b51a-5ada3e2523b6

After:


https://github.com/user-attachments/assets/559bcfe8-a40f-44cc-a626-b0544b6cea68



Release Notes:

- N/A
2025-04-07 20:26:55 +00:00
Hourann
e7a0f0e876 terminal: Fix misaligned mouse selection when inline assist is active (#26112)
This PR fixes an issue where mouse selection in the terminal would be
offset when the Terminal Inline Assistant was active. The problem was
caused by incorrect coordinate translation when handling mouse events
with an active inline assistant.

The fix adjusts mouse event coordinates by properly accounting for the
terminal view's `scroll_top` value when the inline assistant is present,
ensuring that text selection precisely follows the mouse cursor
position.

Closes #26111 

Release Notes:

- Fixed text selection misalignment in terminal when the inline
assistant is active

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-04-07 20:10:14 +00:00
Thorben Kröger
5996c58452 node_runtime: Update to Node 20 (#27912)
Require a newer Node version to make Copilot work

Closes #27908

Release Notes:

- Breaking Change: If using system node Zed now requires Node >= v20.
Previously Node >= v18 was required. (Node v18 EOL date is 2025-04-30;
Node v19 EOL since 2023-06-01). Note: This does not change the Zed
bundled Node runtime version (still v23).
2025-04-07 15:47:04 -04:00
Marshall Bowers
b6ee367ee0 markdown: Don't retain MarkdownStyle in favor of using MarkdownElement directly (#28255)
This PR removes the retained `MarkdownStyle` on the `Markdown` entity in
favor of using the `MarkdownElement` directly and passing the
`MarkdownStyle` to it.

This makes it so switching themes will be reflected live in the code
block styles.

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-04-07 19:03:24 +00:00
tidely
aa026156f2 chore: Make objc a workspace level crate (#28258)
Make objc a workspace level crate to unify version control

Release Notes:

- N/A
2025-04-07 18:46:09 +00:00
Cole Miller
d5cc576b0c Downgrade some logs (#28257)
Closes #ISSUE

Release Notes:

- N/A
2025-04-07 18:41:58 +00:00
Thomas Mickley-Doyle
f3274851d9 Move assistant_evals to agent_evals and remove Judge logic (#28233)
Release Notes:

- N/A
2025-04-07 13:28:06 -05:00
Dallin Huff
500d8f2943 theme: Make Gruvbox terminal ANSI magenta more vibrant (#27166)
Closes #27119

Release Notes:

- Improved contrast of terminal ANSI colors in Gruvbox theme(s)
2025-04-07 18:25:24 +00:00
Julia Ryan
e3830d2ef5 Git activity indicator (#28204)
Closes #26182

Release Notes:

- Added an activity indicator for long-running git commands.

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-04-07 18:10:01 +00:00
Danilo Leal
4f9f443452 agent: Remove duplicated keybinding for creating new thread in Linux (#28254)
Release Notes:

- N/A
2025-04-07 18:09:01 +00:00
Joseph T. Lyons
1556b446e7 Fix titles in issue templates (#28252)
Release Notes:

- N/A
2025-04-07 13:48:31 -04:00
Joseph T. Lyons
5ca8a3e342 Add issue templates for newer flagship features (#28250)
Release Notes:

- N/A
2025-04-07 13:47:12 -04:00
Richard Feldman
aeea3645ff Fix typo in system prompt (#28246)
Release Notes:

- N/A
2025-04-07 17:29:56 +00:00
Conrad Irwin
a577a72f69 Add support for insert_text_mode of a completion (#28171)
I wanted this for CONL (https://conl.dev )'s nascent langauge server,
and it seems like most of the support was already wired up on the LSP
side, so this surfaces it into the editor.

Release Notes:

- Added support for the `insert_text_mode` field of completions from the
language server protocol.
2025-04-07 10:35:11 -06:00
Neo Nie
5a7222edc5 prompt_store: Remove additional code for /project (#27981)
Found leftover from https://github.com/zed-industries/zed/pull/27660

Release Notes:

- N/A
2025-04-07 12:11:14 -04:00
Danilo Leal
097aefeac4 agent: Display keybinding to delete Prompt Editor item (#28168)
This PR makes the keybinding to remove Prompt Editor items visible in
the icon button tooltip.

Release Notes:

- N/A
2025-04-07 13:10:48 -03:00
Smit Barmase
99a9647b78 editor: Fix excerpt down scroll behavior to only scroll when there are enough lines (#28231)
Follow up for https://github.com/zed-industries/zed/pull/27058

Improves excerpt down button to only scroll when there exists lines more
than equal to `expand_excerpt_lines`. This prevents weird shift at end
of the file.

Before:


https://github.com/user-attachments/assets/244a3bd6-d813-4cc8-9dcb-3addba2b652f

After:


https://github.com/user-attachments/assets/a9a9ba62-a454-4b56-9c8a-d8e6931b270b


Release Notes:

- N/A
2025-04-07 21:15:30 +05:30
Richard Feldman
fa90b3a986 Link to cited code blocks (#28217)
<img width="612" alt="Screenshot 2025-04-06 at 9 59 41 PM"
src="https://github.com/user-attachments/assets/3a996b4a-ef5c-4ca6-bd16-3b180b364a3a"
/>

Release Notes:

- Agent panel now shows links to relevant source code files above code
blocks.
2025-04-07 12:01:34 -03:00
Joseph T. Lyons
8049fc1038 Add ai label to agent beta issue template (#28227)
Release Notes:

- N/A
2025-04-07 14:19:09 +00:00
Joseph T. Lyons
85b811a783 Add Agent Panel bug report template (#28226)
Release Notes:

- N/A
2025-04-07 10:10:15 -04:00
张小白
d60dbbc791 windows: Add update-workspace-hack.ps1 script (#28219)
Release Notes:

- N/A
2025-04-07 21:26:26 +08:00
Julia Ryan
656302ee4c Stop centering when selecting larger syntax nodes (#28172)
With #27295, the cursor would center upon running
`SelectLargerSyntaxNode`. This was done to provide more context when
making large selections, but when making small selections (such as a
single parameter in an argument list) it was confusing that the scroll
position jumped.

This change makes that behavior slightly more conservative: now when the
selection is small enough to fit on the screen scrolling will only occur
to keep the cursor position on the screen (including respecting
`vertical_scroll_margin`).

Release Notes:

- N/A

Co-authored-by: João Marcos <marcospb19@hotmail.com>
2025-04-07 06:06:15 -07:00
Smit Barmase
956f359045 project_panel: Add warning error for leading or trailing whitespace when creating file or directory (#28215)
- Show yellow warning (instead or error) for leading/trailing
whitespace.
- Do not block user from creating it.
- If you rename existing file/dir which contains leading/trailing
whitespace, it will show error right away.

<img width="250" alt="image"
src="https://github.com/user-attachments/assets/562895ee-3a86-4ecd-bb38-703d1d8b8599"
/>

Release Notes:

- Added warning for leading or trailing whitespace while renaming or
creating new file or directory in Project Panel.
2025-04-07 17:47:54 +05:30
Smit Barmase
3b46fca64c project_panel: Fix validation error style alignment (#28214)
Use px over rem for positioning as rem is dependent on font
size.

Release Notes:

- N/A
2025-04-07 17:03:21 +05:30
Smit Barmase
d6d9c383cb project_panel: Show error when file or directory already exists while renaming or creating new one (#28177)
Closes #14425

<img width="289" alt="image"
src="https://github.com/user-attachments/assets/2994c401-23e3-419a-90fc-1a83959fdf21"
/>

Release Notes:

- Improved the project panel to show an error when a file or directory
already exists while renaming or creating a new one.
2025-04-07 08:14:22 +05:30
Michael Sloan
8cfb9beb17 Reapply support for X11 screenshare (#28160)
Reapplies #27807 after [revert due to not building on
ARM](https://github.com/zed-industries/zed/pull/28141) by updating scap
to include [a fix to its build on
ARM](08f0a01417)

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-04-06 11:25:29 -06:00
chbk
0708d476ca Improve Bash heredoc highlighting (#28185)
Release Notes:

  - Improved Bash heredoc highlighting

| Zed 0.180.2 | With this PR |
| --- | --- |
|
![Image](https://github.com/user-attachments/assets/aa2534af-53df-4f01-988e-f18ec52a2b62)
|
![Image](https://github.com/user-attachments/assets/8fc92113-41f2-4249-ab81-6beb0a1469ca)
|

```bash
cat << EOT >> hello.txt
hello world
EOT
```

- `<<`: `operator`
- `EOT`: `string`
2025-04-06 11:14:05 -04:00
Agus Zubiaga
57669b4908 agent: Refresh UI when context or thread history changes (#28188)
I found a few more cases where the UI wasn't updated immediately after
an interaction.

Release Notes:

- agent: Fixed delay after removing threads from "Past Interactions"
- agent: Fixed delay after adding/remove context via keyboard
2025-04-06 09:35:15 -05:00
Agus Zubiaga
b1f7133a7b agent: Refresh UI when sending first message (#28180)
Release Notes:

- Agent Beta: Fixed a delay when sending the first message in a new
thread
2025-04-06 08:36:10 -05:00
Richard Feldman
ac9e2f30bb Try to improve behavior when agent is stuck (#28169)
Currently, it's pretty common that when the agent gets stuck, it deletes
whatever it's stuck on and replaces it with a TODO comment, then
cheerfully reports that it has "simpified" the implementation. This is
worse than leaving the broken code, because at least a human could take
over and try to get it across the finish line.

This system prompt adjustment attempts to make the agent do something
more useful when in this situation: report that it's stuck, explain why
it's stuck, and ask the user what to do.

Release Notes:

- N/A
2025-04-05 23:52:28 -04:00
Richard Feldman
a2fbe82c42 If file is too big, provide the outline and suggest a follow-up tool (#28158)
<img width="622" alt="Screenshot 2025-04-05 at 5 48 14 PM"
src="https://github.com/user-attachments/assets/24b9c7d4-d3e2-4929-bca8-79db5b4e5748"
/>

Release Notes:

- The `read_files` tool now reads only the symbol outline files above a
certain size, to conserve context window space. Then it suggests that
the agent call `read_files` again with the relevant line ranges it saw
in the outline.
2025-04-05 18:52:52 -04:00
Richard Hao
57d8c99473 copilot: Create Copilot directory if it does not exist (#28157)
Closes https://github.com/zed-industries/zed/issues/27966

Issue:

- Copilot is failing to launch because the copilot directory is missing

<img width="497" alt="image"
src="https://github.com/user-attachments/assets/af35eb66-7e91-4dc6-a862-d1575da33b5b"
/>


<img width="943" alt="image"
src="https://github.com/user-attachments/assets/0b195c8c-52eb-42b9-bf36-40086398cc3f"
/>


Release Notes:

- copilot: Fixed an issue where GitHub Copilot would not install
properly if the directory was not present.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-04-05 16:06:14 -04:00
Marshall Bowers
41827372fe Revert "Add a next_mode to vim::Paste instead of hard-coding Normal mode (#27897) (#28162)
This PR reverts #27897, as it is causing a number of Helix-related tests
to fail:

```
     Summary [  84.324s] 1796 tests run: 1793 passed, 3 failed, 0 skipped
        FAIL [   0.434s] vim helix::test::test_delete
        FAIL [   0.562s] vim helix::test::test_delete_character_end_of_buffer
        FAIL [   0.537s] vim helix::test::test_delete_character_end_of_line
```

This reverts commit 9949512b64.

Release Notes:

- Community: Reverted https://github.com/zed-industries/zed/pull/27897.
2025-04-05 19:52:56 +00:00
jneem
9949512b64 Add a next_mode to vim::Paste instead of hard-coding Normal mode (#27897)
This adds a `next_mode` parameter to the `vim::Paste` action. My main
use-case for this is for helix users, who will want to switch into
`HelixNormal` mode instead of `Normal` mode.

I'm not sure if this is the best approach -- another possibility would
be to have a global vim-vs-helix configuration, and then have every
invocation of "normal" mode choose vim or helix based on that global
configuration. But the approach in this PR is much less invasive.

Release Notes:

- vim: switch to the configured default mode after paste instead of
hard-coding Normal mode
2025-04-05 12:55:23 -06:00
Kamil Jakubus
b9051e65d4 extension: Bump wasi-sdk to version 25 (#27906)
This bumps `wasi-sdk` to version 25 and adds target architecture
conditionals.

Closes #18492

Release Notes:

- Fixed compiling dev extensions with Tree-sitter grammars on Linux
aarch64.
2025-04-05 13:20:36 -04:00
Marshall Bowers
adbebb28dc assistant: Fix assistant: open prompt library not opening the prompt library (#28156)
This PR fixes the `assistant: open prompt library` action in the command
palette not opening the prompt library when the Assistant Panel did not
have focus.

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

Release Notes:

- assistant: Fixed `assistant: open prompt library` not opening the
prompt library when the Assistant Panel was not focused.
2025-04-05 17:10:39 +00:00
Marshall Bowers
caf0d6c5fa agent: Fix opening configuration view from the model selector (#28154)
This PR fixes an issue where opening the configuration view from the
model selector in the Agent (or inline assist) was not working properly.

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

Release Notes:

- Agent Beta: Fixed an issue where selecting "Configure" in the model
selector would not bring up the configuration view.
2025-04-05 16:32:16 +00:00
Shardul Vaidya
525755c28e bedrock: Add support for tool use, cross-region inference, and Claude 3.7 Thinking (#28137)
Closes #27223
Merges: #27996, #26734, #27949 

Release Notes:

- AWS Bedrock: Added advanced authentication strategies with:
  - Short lived credentials with Session Tokens 
  - AWS Named Profile
  - EC2 Identity, Pod Identity, Web Identity
- AWS Bedrock: Added Claude 3.7 Thinking support.
- AWS Bedrock: Adding Cross Region Inference for all combinations of
regions and model availability.
- Agent Beta: Added support for AWS Bedrock.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-04-05 11:16:26 -04:00
Finn Evers
ea0f5144c9 title_bar: Ensure git onboarding banner dismissal is properly respected (#28147)
A user reported this issue [on
Discord](https://discord.com/channels/869392257814519848/873292398204170290/1357879959422636185).

The issue here only arises for users which recently installed Zed or had
previously not dismissed the Git Onboarding component. It was introduced
by https://github.com/zed-industries/zed/pull/27412, which made the
banner component reusable.

For every banner, there is a value stored in the KVP store when it was
first dismissed. For the git onboarding banner, this was
`zed_git_banner_dismissed_at` initially, but this key would have been
changed by the linked PR. A change would have resulted in the banner
being shown again for users who already dismissed the panel, so for the
special case of `Git Onboarding`, a check was added which ensured this
would not happen.

However, this check was only added for reading from the key from the DB
but not on writing the git onboarding dismissal it to the DB. Thus, if a
user who had not previously dismissed the panel opened Zed, we would
check for the old key to be present in the DB. Since that would not be
the case, the banner would be shown. If the user dismissed the panel, it
would be stored in the database with the new key. Thus, on a reopen of
Zed, the banner would again be shown since for the old key there would
still be no value present and users are unable to dismiss the panel.


This PR fixes this behavior by moving the check into the method that
generates the key. With this, users which were unaffected by the bug
will still not see the panel again. Users who would install Zed with
this change present will be able to properly dismiss the panel aswell.
Users which were affected by the bug need to dismiss the banner one more
time. That happens because I did not want to modify the dismissal check
to check for two keys (the original one and the new one), as it would
clutter the logic even more for this special case. If this would be
preferred, feel free to let me know.

Release Notes:

- Fixed an issue where dismissing the git onboarding banner would not be
persisted across sessions.
2025-04-05 14:33:46 +00:00
Agus Zubiaga
b78ac5410f agent: Fix tool use output rendering (#28146)
Tool use output wouldn't get rendered in some states.

Release Notes:

- N/A
2025-04-05 13:16:24 +00:00
Agus Zubiaga
2462b949bc agent: Add missing notify in ThreadHistory::delete_thread (#28144)
This would cause the history view not to get refreshed immediately when
a thread was deleted

Release Notes:

- agent: Fixed a bug where the history view wouldn't refresh after
deleting a thread
2025-04-05 12:48:10 +00:00
Agus Zubiaga
ec7d28648a agent: Fix thread summary generation (#28143)
#28102 introduced a bug where thread summaries wouldn't get generated
because they would get set to the default title instead of `None`.

Not adding a release note because the bug didn't make it to Preview.

Release Notes:

- N/A
2025-04-05 09:34:23 -03:00
Michael Sloan
c1259c136e Revert "Use scap library to implement screensharing on X11 (#27807)" (#28141)
This reverts commit c2afc2271b.

Build on ARM if failing, likely because `c_char` is `u8` on arm and `i8`
on x86:

```
error[E0308]: mismatched types
   --> /home/runner/.cargo/git/checkouts/scap-40ad33e1dd47aaea/5715067/src/targets/linux/mod.rs:75:74
    |
75  |     let result = unsafe { XmbTextPropertyToTextList(display, &mut xname, &mut list, &mut count) };
    |                           -------------------------                      ^^^^^^^^^ expected `*mut *mut *mut u8`, found `&mut *mut *mut i8`
    |                           |
    |                           arguments to this function are incorrect
    |
    = note:    expected raw pointer `*mut *mut *mut u8`
            found mutable reference `&mut *mut *mut i8`
note: function defined here
   --> /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/x11-2.21.0/src/xlib.rs:552:10
    |
552 |   pub fn XmbTextPropertyToTextList (_4: *mut Display, _3: *const XTextProperty, _2: *mut *mut *mut c_char, _1: *mut c_int) -> c_int,
    |          ^^^^^^^^^^^^^^^^^^^^^^^^^
```

Release Notes:

- N/A
2025-04-05 06:01:27 +00:00
Julia Ryan
6ddad64af1 Add actions for calls (#28048)
Add the following actions for use while calling: `Mute`, `Deafen`,
`ShareProject`, `ScreenShare`, `LeaveCall`

We were also interested in adding push-to-talk functionality for mute,
but that will go in a followup PR

Release Notes:

- Call actions (mute/screenshare/etc.) can now be bound to keys and run from the command palette.

---------

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-04-04 19:32:41 -07:00
Smit Barmase
69d7ea7b60 buffer: Fix broken auto indent when pasting code starting with new line (#28134)
Closes #26907

Currently, in case of new line to find delta, it is comparing old first
line indent with new second line indent. This results into incorrect
indentation. This PR fixes this delta calculation by passing correct
second line indent in that particular case.

- [X] Add Test

Before:


https://github.com/user-attachments/assets/065deba0-be19-4643-a784-d248a8e7c891

After:


https://github.com/user-attachments/assets/a0037043-4bd8-460f-b8ba-b7da7bdbe1ea

Release Notes:

- Fixed issue where pasting code starting with new line resulted
incorrect auto indent.
2025-04-05 05:14:15 +05:30
Conrad Irwin
d0e82b0538 Introduce "Near" block type (#28032)
A "Near" block acts similarly to a "Below" block, but can (if it's
height is <= one line height) be shown on the end of the preceding line
instead of adding an entire blank line to the editor.

You can test it out by pasting this into `go_to_diagnostic_impl` and
then press `F8`
```
        let buffer = self.buffer.read(cx).snapshot(cx);
        let selection = self.selections.newest_anchor();

        self.display_map.update(cx, |display_map, cx| {
            display_map.insert_blocks(
                [BlockProperties {
                    placement: BlockPlacement::Near(selection.start),
                    height: Some(1),
                    style: BlockStyle::Flex,
                    render: Arc::new(|_| {
                        div()
                            .w(px(100.))
                            .h(px(16.))
                            .bg(gpui::hsla(0., 0., 1., 0.5))
                            .into_any_element()
                    }),
                    priority: 0,
                }],
                cx,
            )
        });
        return;
```

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-04-04 17:37:42 -06:00
Smit Barmase
10821aae2c file_finder: Fix filename matching to require contiguous characters (#28093)
Improves https://github.com/zed-industries/zed/pull/27937 to only
prioritize file name if it's contiguous character match.

Release Notes:

- N/A

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-04-05 05:01:56 +05:30
Marshall Bowers
03aadb4e5b telemetry_events: Rename AssistantEvent to AssistantEventData (#28133)
This PR renames the `AssistantEvent` type to `AssistantEventData`, as it
no longer represents the event itself, just the data needed to construct
it.

Pulling out of https://github.com/zed-industries/zed/pull/25179.

Release Notes:

- N/A
2025-04-04 19:28:32 -04:00
Max Brunsfeld
8ab252c42d Split protobufs into separate files (#28130)
The one big protobuf file was getting a bit difficult to navigate. I
split it into separate topic-specific files that import each other.

Release Notes:

- N/A
2025-04-04 16:15:49 -07:00
Michael Sloan
e74af03065 Restore direct use of the input text for Markdown Text (#27620)
PR #24388 changed the markdown parsing to copy parsed text in order to
handle markdown escaping, removing the optimization to instead reuse
text from the input.

Another issue with that change was that handling of finding links within
`Text` intermixed use of `text` and `parsed`, relying on the offsets
matching up (which I believe was true in practice).

The solution is to distinguish pulldown_cmark `Text` nodes that share
bytes with the input and those that do not.

Release Notes:

- N/A
2025-04-04 23:12:32 +00:00
Marshall Bowers
4bcd37a537 agent: Fix panic when opening Agent diff from the workspace (#28132)
This PR fixes a panic that could occur when opening the Agent diff from
the workspace (with the agent panel closed).

Release Notes:

- agent: Fixed a panic when running the `agent: open agent diff` command
with the Agent Panel closed.
2025-04-04 23:09:52 +00:00
Marshall Bowers
e3d212ac60 debugger_ui: Don't .unwrap debug panel access (#28131)
This PR removes replaces the `.unwrap`s when accessing the debug panel
with `if let Some`s.

These `.unwrap`s are not locally verifiable, and thus are not safe.

Release Notes:

- N/A
2025-04-04 22:54:40 +00:00
Finn Evers
8b077f0c41 gpui: Avoid dereferencing null pointer in MacWindow::update_ime_position (#28110)
Seems to be very similar to
https://github.com/zed-industries/zed/pull/28059

Edit: Updated the reproduction-steps as I missed something.

The method without a check currently causes my debug-builds to crash on
the regular if I:
- Run a debug build and open it fullscreen in a dedicated space on my
Mac.
- Work on any of the built-in languages (e.g. remove some content from
any `highlights.scm`)
- Reopen the workspace with the debug-build.
- Crash.

~~We might actually be able to revert the changes made in
https://github.com/zed-industries/zed/pull/21510 and just add the
null-check. Then again, I am not at all sure whether that would work.­~~
See comment below.

Release Notes:

- N/A
2025-04-04 18:20:06 -04:00
Danilo Leal
288da0f072 agent: Use Markdown to render tool input and output content (#28127)
Release Notes:

- agent: Tool call's input and output content are now rendered with
Markdown, which allows them to be selected and copied.

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-04-04 18:53:21 -03:00
Danilo Leal
b8d05bb641 agent: Hide the scrollbar if there's no mouse movement (#28129)
Release Notes:

- agent: The scrollbar now automatically hides if there's no mouse
movement on the thread list.

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-04-04 18:53:11 -03:00
Bennet Bo Fenner
02e4267bc6 Add tool calling support for GitHub Copilot Chat (#28035)
This PR adds tool calling support for GitHub Copilot Chat models.

Currently only supports the Claude family of models.

Release Notes:

- agent: Added tool calling support for Claude models in GitHub Copilot
Chat.

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-04-04 21:41:07 +00:00
Michael Sloan
c2afc2271b Use scap library to implement screensharing on X11 (#27807)
While `scap` does have support for Wayland and Windows, but haven't seen
screensharing work properly there yet. So for now just adding support
for X11 screensharing.

WIP branches for enabling wayland and windows support:

* https://github.com/zed-industries/zed/tree/wayland-screenshare
* https://github.com/zed-industries/zed/tree/windows-screenshare


Release Notes:

- Added support for screensharing on X11 (Linux)

---------

Co-authored-by: Conrad <conrad@zed.dev>
Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Junkui Zhang <364772080@qq.com>
2025-04-04 21:31:03 +00:00
Aaron Feickert
7bc62de267 Use consistent ordering for popup menus (#27765)
Before this change, the editor completion menu and gutter menus reversed their order so that the initial selection is near the user's focus.  This change instead displays these menus in a consistent top-to-bottom order because the following benefits outweigh that benefit:

* Matches behavior of some other editors (Neovim and VSCode).
* Looks better for lexicographic lists.
* Keeps the meaning of keyboard interaction consistent, if the user is anticipating the order of the menu's contents.

Could consider making this configurable in the future if desired.

Closes #25066.

Release Notes:

- N/A
2025-04-04 14:57:09 -06:00
Agus Zubiaga
f3adf41c25 agent: Fix deleting threads in history via keyboard (#28113)
Using `shift-backspace` now because we need `backspace` for search

Release Notes:
- agent: Fix deleting threads in history via keyboard
2025-04-04 17:45:44 -03:00
Kirill Bulatov
6162d9942d Properly query remote ssh server for language servers by name (#28124)
Follow-up of https://github.com/zed-industries/zed/pull/27775

Release Notes:

- N/A
2025-04-04 20:03:51 +00:00
Max Brunsfeld
156dd32a35 Fix panic or bad hunks when expanding hunks w/ multiple ranges in 1 hunk (#28117)
Release Notes:

- Fixed a crash that could happen when expanding diff hunks with
multiple cursors in one hunk.
2025-04-04 12:22:02 -07:00
Ben Kunkle
2747915569 jsx-tag-auto-close: Remove potential source of bugs and panics (#28119)
Switch to using anchors for storing edited ranges rather than offsets as
they have to be used with multiple buffer snapshots

Release Notes:

- N/A
2025-04-04 19:01:08 +00:00
Agus Zubiaga
75b9a3b6a8 agent: Disable redundant tools (might delete later) (#28114)
Release Notes:

- agent: Disable tools that are redundant in the presence of the bash
tool
2025-04-04 18:59:21 +00:00
Marshall Bowers
9bd3dbcf28 collab: Include more information on some LLM usage log lines (#28116)
This PR updates the `user rate limit` and `user usage` log lines to
include some more information that will be useful for graphing in Axiom.

Release Notes:

- N/A
2025-04-04 18:33:23 +00:00
jneem
435fff94bd Flesh out helix bindings (#28103)
This brings in a bunch of helix bindings (many of them from
infogulch/zed-helix-keymap) and implements helix-style delete.

Release Notes:

- vim: Expanded default helix-style keybindings in HelixNormal mode
2025-04-04 12:21:15 -06:00
Marshall Bowers
558d61b907 collab: Adjust rate-limiting measures for Claude 3.7 Sonnet (#28111)
This PR updates the usage measures used for rate limiting when using
Claude 3.7 Sonnet.

Instead of using the combined `tokens_per_minute` measure we now rate
limit individually on `input_tokens_per_minute` (which exclude cache
reads) and `output_tokens_per_minute`.

Release Notes:

- N/A
2025-04-04 13:37:24 -04:00
Bennet Bo Fenner
02a8ece074 agent: Fix invalid tool names in batch tool description (#28109)
The description of the Batch Tool was still referring using `-` as a
seperator for tool names

Release Notes:

- N/A
2025-04-04 17:15:39 +00:00
Marshall Bowers
1a899fda60 collab: Capture upstream input/output rate limits from Anthropic (#28106)
This PR makes it so we capture the upstream rate limit information from
Anthropic for input and output tokens.

Release Notes:

- N/A
2025-04-04 17:09:00 +00:00
Marshall Bowers
183f57f318 collab: Include max input/output tokens per minute on "Language Model Rate Limited" event (#28108)
This PR adds the max input/output tokens per minute on the "Language
Model Rate Limited" event.

Missed this in https://github.com/zed-industries/zed/pull/28097.

Release Notes:

- N/A
2025-04-04 16:57:43 +00:00
Agus Zubiaga
cc9cc12f7b agent: Remove edit_files tool (#28041)
Release Notes:

- agent: Remove `edit_files` tool  in favor of `find_replace`
2025-04-04 16:37:14 +00:00
Agus Zubiaga
1bc5618f61 agent: Allow renaming threads (#28102)
Release Notes:

- agent: Add support for renaming threads

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-04-04 13:24:33 -03:00
Richard Feldman
ef8fe52877 Try adding beta token-efficient tool use for 3.7 Sonnet (#28100)
Release Notes:

- Enabled [token-efficient tool use
(beta)](https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use)
for Claude 3.7 Sonnet models
2025-04-04 11:05:41 -05:00
Marshall Bowers
982196343f Fix script/zed-local on non-Windows platforms (#28098)
This PR fixes the `script/zed-local` script, which was no longer working
properly after https://github.com/zed-industries/zed/pull/23117.

Release Notes:

- N/A
2025-04-04 16:03:57 +00:00
Danilo Leal
cfe5620a2a docs: Adjust assistant configuration docs table of contents (#28099)
Follow up https://github.com/zed-industries/zed/pull/28088

The "Feature-specific models" was under the LM Studio section, which was
incorrect.

Release Notes:

- N/A
2025-04-04 12:47:52 -03:00
Marshall Bowers
5fe86f7e70 collab: Track input and output tokens per minute separately (#28097)
This PR adds tracking for input and output tokens per minute separately
from the current aggregate tokens per minute.

We are not yet rate-limiting based on these measures.

Release Notes:

- N/A
2025-04-04 15:37:06 +00:00
Finn Evers
c94b587e1a squawk: Specify PostgreSQL version in config (#28094)
This PR adds the PostgreSQL version to the squawk config, see
https://squawkhq.com/docs/cli#specifying-postgres-version for reference.

The specified version matches the PostgreSQL version in the compose-file


43cb925a59/compose.yml (L3)

and prevents false positives like
https://github.com/zed-industries/zed/pull/28090#issuecomment-2778871346
from happening (tested it locally with that commit).

Release Notes:

- N/A
2025-04-04 09:32:30 -06:00
Agus Zubiaga
43cb925a59 ai: Separate model settings for each feature (#28088)
Closes: https://github.com/zed-industries/zed/issues/20582

Allows users to select a specific model for each AI-powered feature:
- Agent panel
- Inline assistant
- Thread summarization
- Commit message generation

If unspecified for a given feature, it will use the `default_model`
setting.

Release Notes:

- Added support for configuring a specific model for each AI-powered
feature

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-04-04 11:40:55 -03:00
Marshall Bowers
cf0d1e4229 collab: Add granular tokens per minute columns to models table (#28090)
This PR adds new granular tokens per minute columns to the `models`
table in preparation for more fine-grained rate limits.

The following columns have been added:

- `max_input_tokens_per_minute`
- `max_output_tokens_per_minute`

These mirror the "Maximum input tokens per minute (ITPM)" and "Maximum
output tokens per minute (OTPM)" [rate limits from
Anthropic](https://docs.anthropic.com/en/api/rate-limits#rate-limits).

Release Notes:

- N/A
2025-04-04 14:33:15 +00:00
Artem Evsikov
2f5a4f7e80 tasks: Add spawn option by tag (#25650)
Closes #19497
Fixed conflicts from https://github.com/zed-industries/zed/pull/19498
Added tags to tasks selector

Release Notes:

- Added ability to spawn tasks by tag with key bindings
- Added tags to tasks selector


https://github.com/user-attachments/assets/0eefea21-ec4e-407c-9d4f-2a0a4a0f74df

---------

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
2025-04-04 14:20:09 +00:00
tidely
80441f675b gpui: Use NSOperatingSystemVersion provided by cocoa (#28055)
Use the `NSOperatingSystemVersion` struct provided by the cocoa crate
instead of our own. Additionally we can directly use
`isOperatingSystemAtLeastVersion` instead of manually implementing
version comparison logic.

The `isOperatingSystemAtLeastVersion` instance method has been available
since MacOS 10.10, which released a decade ago.

Documentation for `isOperatingSystemAtLeastVersion `:
https://developer.apple.com/documentation/foundation/nsprocessinfo/1414876-isoperatingsystematleastversion

Release Notes:

- N/A
2025-04-04 09:33:25 -04:00
Peter Tripp
393d6560a3 Make CloseAll keybindings more closely match VS Code (#28060)
Changes default keymaps to more closely match the behavior of VSCode.

New Zed behavior:
`cmd-k w` / `ctrl-k w` -- Closes all buffers in the current pane
`cmd-k cmd-w` / `ctrl-k ctrl-w` -- Closes all buffers in all panes

VScode:
`cmd-k cmd-w` is workbench.action.closeAllEditors (close all buffers in
all splits)
`cmd-k w` is workbench.action.closeEditorsInGroup (close all buffers in
current split)

Both leave pinned tabs untouched.

Release Notes:

- Improved keybindings for close all tabs to better match VSCode
behavior
2025-04-04 09:15:41 -04:00
Agus Zubiaga
3d48efad67 agent: Add search to Thread History (#28085)
![CleanShot 2025-04-04 at 09 45
47@2x](https://github.com/user-attachments/assets/a8ec4086-f71e-4ff4-a5b3-4eb5d4c48294)


Release Notes:

- agent: Add search box to thread history

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-04-04 10:09:21 -03:00
Antonio Scandurra
277a3f8d6f Implement edit rejection in ActionLog (#28080)
Release Notes:

- Fixed a bug that would prevent rejecting certain agent edits.
2025-04-04 11:20:18 +00:00
Jake
5e286897d3 Escape carets (^) in Go test regex (#27746)
This is a follow up to https://github.com/zed-industries/zed/pull/14821,
which escaped `$` but not `^`.

This is fine for `bash`, but causes issues with `zsh`. This change
escapes the `^`. I tested this against `bash`, `zsh` and `fish`

I suspect such escaping would probably need to be done at some
shell-specific layer of the code, but for now it seems like the tasks
provided by the `ContextProvider` are supposed to be shell agnostic.

To reproduce the original issue:
1. Create a Go test file in a module that just contains a single test
`TestABC`.
2. Run `zsh -i -c "go test -run ^TestABC\$"` which is what Zed tries to
run when the task for a specific Go test is executed.
3. An error that there are no tests to run will be produced even though
there is a test.
4. Run `zsh -i -c "go test -run \^TestABC\$"` (note the backslash before
^).
5. The test will run successfully.

Example:
``` go
package bar

import "testing"

func TestABC(t *testing.T) {}
```

Release Notes:

- fix: Escape the ^ in the Go test -run regex to improve shell
compatibility (notably with zsh).
2025-04-04 12:04:38 +02:00
Bennet Bo Fenner
9e38c45a9b agent: Show which lines were read when using read_file tool (#28077)
This makes sure that we specify which lines the agent actually read,
avoids confusing scenarios such as:

<img width="642" alt="Screenshot 2025-04-04 at 10 22 10"
src="https://github.com/user-attachments/assets/2680c313-4f77-4971-8743-8e3f5327c18d"
/>

Here the agent starts out by actually only reading a certain amount of
lines when the first tool call happens, then it does a second tool call
to read the whole file. To the user this looks like to identical tool
calls.

Now:
<img width="621" alt="image"
src="https://github.com/user-attachments/assets/76222258-9cc8-4b7c-98c0-6d5cffb282f2"
/>
<img width="362" alt="image"
src="https://github.com/user-attachments/assets/293f2fc0-365d-4b84-8400-4c11474caeb8"
/>
<img width="420" alt="image"
src="https://github.com/user-attachments/assets/ca92493e-67ce-4d45-8f83-0168df575326"
/>



Release Notes:

- N/A
2025-04-04 09:40:05 +00:00
Bennet Bo Fenner
1db3d92066 agent: Differentiate @mentions from markdown links (#28073)
This ensures that we display @mentions and normal markdown links
differently:

<img width="670" alt="Screenshot 2025-04-04 at 11 07 51"
src="https://github.com/user-attachments/assets/0a4d0881-abb9-42a8-b3fa-912cd6873ae0"
/>


Release Notes:

- N/A
2025-04-04 09:39:48 +00:00
Antonio Scandurra
a7674d3edc Scroll to first hunk when clicking on a file to review in Agent Panel (#28075)
Release Notes:

- Added the ability to scroll to a file when clicking on it in the Agent
Panel review section.
2025-04-04 09:30:35 +00:00
Conrad Irwin
ee4b6a8db4 Listen for changes to the configuration of the attached device too (#28045)
Release Notes:

- Fixed an issue causing "robot voice" when enabling the microphone on
some bluetooth headphones (hopefully).

Co-authored-by: Zed AI <ai+claude-3.7@zed.dev>
2025-04-03 21:05:54 -06:00
Rahul Butani
c04c5812b6 nix: Fix the cargo-bundle override (#28061)
With the recent deprecation of `rustPlatform.fetchCargoTarball` +
migration to using `fetchCargoVendor` by default in `buildRustPackage`
(NixOS/nixpkgs#394012), the `cargo-bundle` override strategy used here,
as prescribed by the
[nixos asia wiki](https://nixos.asia/en/buildRustPackage) no longer
works:

c6e2d20a02/nix/build.nix (L100-L116)

[`fetchCargoTarball` produced a single derivation][tarball-drv] but
`fetchCargoVendor` [produces two][vendor-drvs]:
  - `${name}-vendor-staging` (inner; FoD)
  - `${name}-vendor` (outer)

[tarball-drv]:
36fd87baa9/pkgs/build-support/rust/fetch-cargo-tarball/default.nix (L79)
[vendor-drvs]:
10214747f5/pkgs/build-support/rust/fetch-cargo-vendor.nix (L52-L103)

`overrideAttrs` here is setting `outputHash` on the latter (which isn't
a fixed-output-derivation and does not have `outputHashMode` set which
implies `outputHashMode = "flat"`) instead of the inner; this results in
errors like this:
```console
❯ nix develop
error: output path '/nix/store/cb57w05zvsqxshqjl789kmsy9pbqjn06-cargo-bundle-0.6.1-zed-vendor.tar.gz' should be a non-executable regular file since recursive hashing is not enabled (outputHashMode=flat)
error: 1 dependencies of derivation '/nix/store/k3azmxljgjn26hqyhg9m1y3lhx32y939-cargo-bundle-0.6.1-zed.drv' failed to build
error: 1 dependencies of derivation '/nix/store/8ag4v0m90m4kcaq1ypp7f85pp8s6fxgc-nix-shell-env.drv' failed to build
```

> [!NOTE]
> you will need to remove
`/nix/store/cb57w05zvsqxshqjl789kmsy9pbqjn06-cargo-bundle-0.6.1-zed-vendor.tar.gz`
> from your nix store in order to be able to reproduce this

We want to be setting `outputHash` on the [first derivation][first-drv]
instead. This change has us just do the call to `fetchCargoTarball`
manually instead of using overrides.

[first-drv]:
10214747f5/pkgs/build-support/rust/fetch-cargo-vendor.nix (L85)

---

I suspect CI/other machines didn't catch this due to a store path
matching the name + `outputHash` already being present but I'm not
entirely sure how this happened...

`sha256-Q49FnXNHWhvbH1LtMUpXFcvGKu9VHwqOXXd+MjswO64=` is actually a
`fetchCargoTarball` hash, not a `fetchCargoVendor` hash (and upstream
`cargo-about`'s `cargoDeps` [has been using `cargoVendor`][ups] since
before the nixpkgs bump in 50ad71a630)

[ups]:
1d09c579c1/pkgs/by-name/ca/cargo-about/package.nix (L22)

---

> [!NOTE]
> eventually we'll be able to just have `.overrideAttrs (_: { cargoHash
= "..."; })` work as expected [^2]

---

Release Notes:

- N/A

[^2]:
[now that
`buildRustPackage`](https://github.com/NixOS/nixpkgs/pull/382550) uses
[`lib.extendMkDerivation`](bbdf8601bc/doc/build-helpers/fixed-point-arguments.chapter.md)
(NixOS/nixpkgs/#234651) the groundwork is in place; a follow PR [needs
to use `cargoHash` and friends from
`finalAttrs`](10214747f5/pkgs/build-support/rust/build-rust-package/default.nix (L104))
2025-04-03 23:15:49 +00:00
Peter Tripp
cba96b5a38 ci: Prettier GitHub Actions display (#28062)
Skipped nix builds were ugly, showing raw template when being skipped. Make prettier.

Release Notes:

- N/A
2025-04-03 23:12:33 +00:00
Nathan Sobo
8b5ea05163 Fix panic calling blocks_intersecting_buffer_range with an empty range (#28049)
Previously, when comparing a block with an empty range to an empty query
range in non-inclusive mode, our binary search logic could end up
computing an inverted range, causing a panic.

This commit adds special casing when comparing empty blocks with empty
ranges.

cc @as-cii: I'm realizing that the approach to searching for the
intersecting replacement blocks makes some invalid assumptions about the
ordering of replace decorations. They aren't ordered at all by their end
range. @maxbrunsfeld and I are wondering if long term, we should remove
replace decorations and find another solution for folding buffers in
multi buffers.

Release Notes:

- Fixed an occasional panic that would occur when navigating to the next
change hunk with a pending inline transformation present.

Co-authored-by: Peter Tripp <petertripp@gmail.com>
2025-04-03 16:50:49 -06:00
Marshall Bowers
ec40e2d85c gpui: Avoid dereferencing null pointer in MacWindow::active_window (#28059)
This PR adds a check to avoid dereferencing a null pointer in
`MacWindow::active_window`.

Rust 1.86 now has a [debug assertion for dereferencing null
pointers](https://blog.rust-lang.org/2025/04/03/Rust-1.86.0.html#debug-assertions-that-pointers-are-non-null-when-required-for-soundness),
which means that losing focus of the window would cause a null pointer
to be dereferenced and panic.

Release Notes:

- N/A
2025-04-03 22:47:13 +00:00
Marshall Bowers
819bb8fffb open_ai: Disable parallel_tool_calls (#28056)
This PR disables `parallel_tool_calls` for the models that support it,
as the Agent currently expects at most one tool use per turn.

It was a bit of trial and error to figure this out. OpenAI's API
annoyingly will return an error if passing `parallel_tool_calls` to a
model that doesn't support it.

Release Notes:

- N/A
2025-04-03 22:07:37 +00:00
Piotr Osiewicz
c6e2d20a02 chore: Bump Rust version to 1.86 (#28021)
Closes #ISSUE

Release Notes:

- N/A
2025-04-03 23:32:50 +02:00
Marshall Bowers
7492ec3f67 Add tool use support for OpenAI models (#28051)
This PR adds support for using tools to the OpenAI models.

Release Notes:

- agent: Added support for tool use with OpenAI models (Preview only).
2025-04-03 20:55:11 +00:00
Julia Ryan
4d8df0a00b Add nix CI (#28036)
This adds a nix CI job to build the flake in debug mode for
aarch64-darwin and x86-linux. For now this job will only run when the
`run-nix` label is added to a PR.

The CI job doesn't push to cachix for now, so every build is a clean
build.

I also added a condition to the garbage collection step so it only runs
when the nix store is >50GB.

Release Notes:

- N/A
2025-04-03 12:55:18 -07:00
Kirill Bulatov
3f71ae9897 Use more appropriate action for Vim word completions (#28043)
Follow-up of https://github.com/zed-industries/zed/pull/26410

The action does not sort the items the way Vim does, but still better
than the previous state.

Release Notes:

- N/A

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-04-03 19:32:24 +00:00
Nate Butler
2086f7d85b ui_input: TextField -> SingleLineInput (#28031)
- Rename `TextField` -> `SingleLineInput`
- Add a component preview for `SingleLineInput`
- Apply `SingleLineInput` to the AddContextServerModal

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
2025-04-03 16:00:43 -03:00
Agus Zubiaga
315f1bf168 agent: Snapshot context in user message instead of recreating it (#27967)
This makes context essentially work the same way as `read-file`,
increasing the likelihood of cache hits.

Just like with `read-file`, we'll notify the model when the user makes
an edit to one of the tracked files. In the future, we want to send a
diff instead of just a list of files, but that's an orthogonal change.


Release Notes:
- agent: Improved caching of files in context

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-04-03 15:52:28 -03:00
Kirill Bulatov
0c82541f0a Allow to temporarily stop LSP servers (#28034)
Same as `editor::RestartLanguageServer`, now there's an
`editor::StopLanguageServer` action that stops all language servers,
related to the currently opened editor.

Opening another singleton editor with the same language or changing
selections in a multi buffer will bring the servers back up.

Release Notes:

- Added a way to temporarily stop LSP servers

---------

Co-authored-by: Michael Sloan <mgsloan@gmail.com>
2025-04-03 12:50:43 -06:00
Danilo Leal
b9724d9cbe agent: Add token count in the thread view (#28037)
This PR adds the token count to the active thread view. It doesn't
behaves quite like Assistant 1 where it updates as you type, though; it
updates after you submit the message.

<img
src="https://github.com/user-attachments/assets/82d2a180-554a-43ee-b776-3743359b609b"
width="700" />

---

Release Notes:

- agent: Add token count in the thread view

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-04-03 15:43:58 -03:00
Marshall Bowers
e5b347b03a Remove unused extract_tool_args_from_events functions (#28038)
This PR removes the unused `extract_tool_args_from_events` functions
that were defined in some of the LLM provider crates.

Release Notes:

- N/A
2025-04-03 18:38:35 +00:00
Antonio Scandurra
e123c4bced Fix soft-wrapping with fold creases (#28029)
Release Notes:

- Fixed a rendering bug that caused context in the agent to not wrap
properly.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Zed AI <ai+claude-3.7@zed.dev>
2025-04-03 17:33:08 +00:00
Agus Zubiaga
ed3722023e agent: Handle tool use without text (#28030)
### Context 

The Anthropic API fails if a request message contains a tool use and no
`Text` segments or it only contains empty `Text` segments. These are
cases that the model itself produces, but the API doesn't support
sending them back.

#27917 fixed this by appending "Using tool..." in the thread's message,
but this causes the actual conversation to include it, so it would
appear in the UI (we would actually display a gap because we never
rendered its markdown, but "Using tool..." would show up when the thread
was restored).

### Solution

We'll now only append this placeholder when we build the request, so the
API still sees it, but the UI/Thread doesn't.

Another issue we found is that the model starts mimicking these
placeholders in later tool uses which is undesirable. So unfortunately,
we had to add logic to filter them out.

Release Notes:

- agent: Improved rendering of tool uses without text

---------

Co-authored-by: Bennet <bennet@zed.dev>
2025-04-03 14:22:59 -03:00
Piotr Osiewicz
ece4a1cd7c debugger: Start on tabless design (#27837)
![image](https://github.com/user-attachments/assets/1cd54b70-5457-4c64-95bd-45a7055ea165)

Release Notes:

- N/A

---------

Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Anthony <anthony@zed.dev>
2025-04-03 16:11:14 +00:00
Danilo Leal
9986a21970 agent: Add button to continue iterating once all reviews are done (#28027)
This PR adds a button on the review tab empty state that toggles the
focus back to the agent panel so that users can keep iterating on the
thread that's active in the panel.

<img
src="https://github.com/user-attachments/assets/ace5cf93-8869-49bb-8106-e03a9e3c90f2"
width="700"/>

Release Notes:

- N/A
2025-04-03 12:32:02 -03:00
Kirill Bulatov
c674e8d62d Clear path-based excerpt data properly (#28026)
Follow-up of https://github.com/zed-industries/zed/pull/27893

Release Notes:

- N/A

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-04-03 15:17:10 +00:00
Finn Evers
e5e3e9ac8c rust: Improve runnable detection for test modules (#28024)
Closes #28002

This PR updates the `runnabless.scm` for Rust to improve detection of
test modules with non-standard names. Instead of matching on the module
name, we now check for the `#[cfg(test)]`-attribute above test modules.
This allows for generic matching whilst not regressing the previous
behaviour.

| `main` | <img width="922" alt="main"
src="https://github.com/user-attachments/assets/34fc4443-13a2-4e18-b806-7e14771c3df4"
/> |
| --- | --- |
| This PR | <img width="922" alt="PR"
src="https://github.com/user-attachments/assets/13e6a6d8-e177-4a83-89ab-24c0a69ade27"
/> |

Release Notes:

- Improved runnable detection for test modules in Rust.
2025-04-03 14:56:28 +00:00
Cole Miller
399d19231b Temporarily disable flaky conflicted-cherry-pick test (#27950)
Closes #ISSUE

Release Notes:

- N/A
2025-04-03 10:51:23 -04:00
Bennet Bo Fenner
c98bcc72b8 agent: Fix thinking step showing up as pending when completion is cancelled (#28019)
Previously the "Thinking..." step would show up as pending, even though
the user cancelled the generation:
<img width="672" alt="image"
src="https://github.com/user-attachments/assets/c9cdce0a-d827-4e23-96f5-b150465911a7"
/>


Release Notes:

- Fixed an issue where the thinking step would show up as pending even
when the generation was cancelled
2025-04-03 13:35:06 +00:00
Agus Zubiaga
fe27d11f08 agent: Include active file in recent history (#27914)
This happened because of two reasons:

- `Workspace::recent_navigation_history` didn't include the current file
- The context picker added the current file to a exclude list

The latter was actually intentional because we already show the file in
the suggested context, but now that we actually have mentions, it's just
inconvenient not to have it there.

Release Notes:

- N/A
2025-04-03 13:29:41 +00:00
Finn Evers
9693eab098 editor: Fix active line number highlighting regression (#28015)
This PR resolves a small regression introduced by the
debugger-introduction, which causes the active line number to no longer
be highlighted in the gutter as long as it is not part of a selection. A
user reported this issue [on
Discord](https://discord.com/channels/869392257814519848/995403703894954060/1357153291913662567).

Prior to the debugger-commit, an active line number was highlighted if
it was part of the editor active line numbers:

ed4e654fdf/crates/editor/src/element.rs (L4295-L4303)

With the debugger-introduction, the code was changed to only highlight
lines which are part of a selection:

e2aaf9b704/crates/editor/src/element.rs (L2411-L2422)

However, the check whether it is within a selection is not neccesary, as
the line is an active line as long as it is within the map of active
lines.

This PR restores the previous behavior.

| `main` | <img width="922" alt="main"
src="https://github.com/user-attachments/assets/486a548d-fe09-450e-922e-1feb4366fb4f"
/> |
| --- | --- |
| This PR | <img width="922" alt="PR"
src="https://github.com/user-attachments/assets/80517880-14b5-4861-bf83-8364f7831c46"
/> |

Release Notes:

- Fixed an issue where the active line number in the editor was not
always highlighted.
2025-04-03 11:32:15 +00:00
Piotr Osiewicz
e2aaf9b704 chore: Remove stray eprintln (#28014)
Closes #ISSUE

Release Notes:

- N/A
2025-04-03 10:28:27 +00:00
张小白
9abfbdff43 Fix test_peers_following_each_other for Windows (#28008)
Release Notes:

- N/A
2025-04-03 15:47:33 +08:00
Thomas Mickley-Doyle
cd85b430e4 assistant_eval: Add ACE framework (#27181)
Release Notes:

- N/A

---------

Co-authored-by: Michael Sloan <michael@zed.dev>
2025-04-02 23:02:06 -05:00
Julia Ryan
d3e4de7c72 workspace-hack: remove openssl from remote_server (#27990)
This was accidentally getting added due to increased feature
unification. We've manually excluded reqwest to go back to the desired
behavior: remote_server, doesn't depend on openssl.

Release Notes:

- N/A

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-04-03 00:49:07 +00:00
Piotr Osiewicz
ee950f5bc4 Debugger: Add pretty printers for Cargo-located tasks (#27979)
Closes #ISSUE

Release Notes:

- N/A
2025-04-03 01:40:08 +02:00
Smit Barmase
501b539286 gpui: Fix background for WrappedLine (#27980)
https://github.com/zed-industries/zed/pull/26454 In this PR, we
separated painting for text line into two parts: `paint` and
`paint_background`. This allows selections to appear in front of the
text background but behind the text itself in the editor.

The `paint_background` method was implemented for `ShapedLine` but not
for `WrappedLine`. This PR adds that, fixing the background rendering
for inline code blocks in Markdown, as they use `WrappedLine`.

Before:
<img width="160" alt="image"
src="https://github.com/user-attachments/assets/81466c63-6835-4128-ba22-1b63f5fd7b1f"
/>

After:
<img width="160" alt="image"
src="https://github.com/user-attachments/assets/3b7044d7-265b-45db-904c-3b70fdf421fe"
/>

Release Notes:

- Fixed missing background for inline code blocks in the editor hover
tooltip.
2025-04-03 05:09:42 +05:30
Marshall Bowers
444b7b8acb renovate: Ignore Cargo.toml for workspace-hack (#27976)
This PR adds the `Cargo.toml` for the `workspace-hack` crate to the
ignore list for Renovate, as it is opening a number of PRs against it
that will interfere with it.

Release Notes:

- N/A
2025-04-02 23:24:46 +00:00
Kirill Bulatov
8a6ed4a2ca Use new multibuffer excerpts in project search (#27893)
Follow-up of https://github.com/zed-industries/zed/pull/27876
Closes https://github.com/zed-industries/zed/issues/13513

Release Notes:

- Improved multi buffer excerpts to merge when expanded

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-04-02 22:57:40 +00:00
5brian
b4af5b2ce0 agent: Update thread label to use plural form (#27971)
Update thread label to match the other contexts.

|Before|After|
|--|--|

|![image](https://github.com/user-attachments/assets/6e02808e-50d7-480f-a9ca-251e9519a71d)|![image](https://github.com/user-attachments/assets/174aad84-9e55-4531-bb4a-1a1adaa46418)|

Release Notes:

- N/A
2025-04-02 18:33:05 -04:00
Marshall Bowers
ee33d313e2 agent: Allow editing previous messages (#27965)
This PR adds the ability to edit previous user messages in the thread.

Release Notes:

- Agent: Added the ability to edit previous user messages
(Preview-only).
2025-04-02 21:05:49 +00:00
Danilo Leal
0a132779a1 agent: Change loading label if command is waiting on permission (#27955)
If there's a command pending confirmation, the label changes from
"Generating" to "Waiting for confirmation".

<img
src="https://github.com/user-attachments/assets/d804e382-5315-40b0-9588-c257cca2430c"
width="600"/>

Release Notes:

- N/A
2025-04-02 17:41:16 -03:00
Danilo Leal
d23c2d4b02 agent: Refine feedback message input (#27948)
<img
src="https://github.com/user-attachments/assets/cde37a88-9973-4c27-80b7-459f5e986c74"
width="650" />

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2025-04-02 17:41:07 -03:00
Max Brunsfeld
b9f10c0adb Fix redundant FS file watches due to LSP path watching (#27957)
Release Notes:

- Fixed a bug where Zed sometimes added multiple redundant FS watchers
when language servers requested to watch paths. This could cause saves
and git operations to fail if Zed exceeded the file descriptor limit.

---------

Co-authored-by: Piotr <piotr@zed.dev>
2025-04-02 13:36:28 -07:00
Smit Barmase
9f9746872e editor: Fix typing closing bracket skips it even when use_autoclose is disabled (#27960)
Closes #27769

When adding snippet we were not respecting autoclose setting, before
creating AutocloseRegion. This leads to cursor to skip over instead of
typing that character. This PR fixes it.

Release Notes:

- Fixed certain case where typing closing bracket would skip it when
auto close setting is turned off.
2025-04-03 02:00:44 +05:30
Julia Ryan
01ec6e0f77 Add workspace-hack (#27277)
This adds a "workspace-hack" crate, see
[mozilla's](https://hg.mozilla.org/mozilla-central/file/3a265fdc9f33e5946f0ca0a04af73acd7e6d1a39/build/workspace-hack/Cargo.toml#l7)
for a concise explanation of why this is useful. For us in practice this
means that if I were to run all the tests (`cargo nextest r
--workspace`) and then `cargo r`, all the deps from the previous cargo
command will be reused. Before this PR it would rebuild many deps due to
resolving different sets of features for them. For me this frequently
caused long rebuilds when things "should" already be cached.

To avoid manually maintaining our workspace-hack crate, we will use
[cargo hakari](https://docs.rs/cargo-hakari) to update the build files
when there's a necessary change. I've added a step to CI that checks
whether the workspace-hack crate is up to date, and instructs you to
re-run `script/update-workspace-hack` when it fails.

Finally, to make sure that people can still depend on crates in our
workspace without pulling in all the workspace deps, we use a `[patch]`
section following [hakari's
instructions](https://docs.rs/cargo-hakari/0.9.36/cargo_hakari/patch_directive/index.html)

One possible followup task would be making guppy use our
`rust-toolchain.toml` instead of having to duplicate that list in its
config, I opened an issue for that upstream: guppy-rs/guppy#481.

TODO:
- [x] Fix the extension test failure
- [x] Ensure the dev dependencies aren't being unified by Hakari into
the main dependencies
- [x] Ensure that the remote-server binary continues to not depend on
LibSSL

Release Notes:

- N/A

---------

Co-authored-by: Mikayla <mikayla@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-04-02 13:26:34 -07:00
frederik-uni
07a77792c5 Add completions.lsp_insert_mode setting to control what ranges are replaced when a completion is inserted (#27453)
This PR adds `completions.lsp_insert_mode` and effectively changes the
default from `"replace"` to `"replace_suffix"`, which automatically
detects whether to use the LSP `replace` range instead of `insert`
range.

`"replace_suffix"` was chosen as a default because it's more
conservative than `"replace_subsequence"`, considering that deleting
text is usually faster and less disruptive than having to rewrite a long
replaced word.

Fixes #27197
Fixes #23395 (again)
Fixes #4816 (again)

Release Notes:

- Added new setting `completions.lsp_insert_mode` that changes what will
be replaced when an LSP completion is accepted. The default is
`"replace_suffix"`, but it accepts 4 values: `"insert"` for replacing
only the text before the cursor, `"replace"` for replacing the whole
text, `"replace_suffix"` that acts like `"replace"` when the text after
the cursor is a suffix of the completion, and `"replace_subsequence"`
that acts like `"replace"` when the text around your cursor is a
subsequence of the completion (similiar to a fuzzy match). Check [the
documentation](https://zed.dev/docs/configuring-zed#LSP-Insert-Mode) for
more information.

---------

Co-authored-by: João Marcos <marcospb19@hotmail.com>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-04-02 16:55:03 -03:00
Anthony Eid
108ae0b5b0 debugger: Add args argument to debugger launch config (#27953)
This also fixes a bug where debug cargo test code actions would debug
all tests in a mod instead of a specific test

Release Notes:

- N/A
2025-04-02 15:37:12 -04:00
Andy Waite
500964a6fa docs: Add example of Ruby plain minitest task (#27607)
Via
https://github.com/zed-industries/zed/issues/12579#issuecomment-2143972765

Release Notes:

- N/A
2025-04-02 15:24:59 -04:00
Shardul Vaidya
0a58e54477 aws_http_client: Copy response headers (#27941)
Preemptive fixes required for #26734

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-04-02 15:00:44 -04:00
Ben Kunkle
8539e23018 zed: Include full debug info in debug builds (#27924)
Closes #ISSUE

Release Notes:

- N/A
2025-04-02 18:39:30 +00:00
Marshall Bowers
c7d27753ee agent: Do some cleanup of feedback comments submission (#27940)
This PR does some stylistic cleanup of the feedback comments submission
code.

Release Notes:

- N/A
2025-04-02 18:03:18 +00:00
Michael Sloan
b7b7f1ccdd Use worktree qualified paths in agent file context + some code cleanup (#27943)
Release Notes:

- N/A
2025-04-02 18:00:32 +00:00
Michael Sloan
142f9917d0 Fix clippy lints that don't currently appear in CI (#27944)
I may have a newer version of clippy than CI. Also removes some unused
code in `livekit_client.rs`

Release Notes:

- N/A
2025-04-02 18:00:16 +00:00
Joseph T. Lyons
f8092bf0d2 Bump Zed to v0.182 (#27945)
Release Notes:

-N/A
2025-04-02 13:45:42 -04:00
Anthony Eid
0ba8432b0b Debugger: Add stop on entry support to debug adapter configs (#27942)
This PR adds passing in `stop_on_entry` to debug configs in debug.json
instead of going through initialization args.

This has two benefits:

1. It's more streamlined to a user since every internal adapter supports
`stop_on_entry` for launch requests and Go's adapter supports it for
attach requests too.
2. It will allow @osiewicz `NewSesssionModal` PR to use this field for
the stop on entry checkbox.

Release Notes:

- N/A
2025-04-02 13:45:26 -04:00
Nate Butler
9682c0d0ec Add label component 2025-03-21 12:56:58 -04:00
Nate Butler
6d01add099 Fix scrolling 2025-03-21 12:56:50 -04:00
Nate Butler
bcb5ed8817 wip page style 2025-03-15 20:42:15 -04:00
Nate Butler
b0146a76ae Allow passing an active page when opening component preview 2025-03-15 08:58:46 -04:00
Nate Butler
393b65bdc9 Fix unused, remove redundant stories 2025-03-15 08:50:40 -04:00
Nate Butler
2285d934ff Add missing disabled to dropdown menu 2025-03-15 08:37:10 -04:00
Nate Butler
c48f68e2de Add more components 2025-03-15 08:35:34 -04:00
Nate Butler
5cbd7911a8 wip 2025-03-14 21:19:52 -04:00
Nate Butler
eacd97b455 wip 2025-03-14 17:27:40 -04:00
Nate Butler
acef5c743c wip 2025-03-14 17:09:12 -04:00
Nate Butler
2a08f81c31 Start on component preview page 2025-03-14 13:21:22 -04:00
Nate Butler
dd1404cfcb More preview updates 2025-03-14 11:49:59 -04:00
Nate Butler
dbf8954173 Enable sorting within component preview scopes 2025-03-14 11:46:33 -04:00
Nate Butler
bf4c0aab06 wip work on updating to Component 2025-03-14 11:29:18 -04:00
Nate Butler
1c99932991 Remove the derive_component macro 2025-03-13 22:04:17 -04:00
Nate Butler
04d86d2e92 Remove the ComponentPreview trait 2025-03-13 22:03:10 -04:00
549 changed files with 24893 additions and 16081 deletions

43
.config/hakari.toml Normal file
View File

@@ -0,0 +1,43 @@
# This file contains settings for `cargo hakari`.
# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options.
hakari-package = "workspace-hack"
resolver = "2"
dep-format-version = "4"
workspace-hack-line-style = "workspace-dotted"
# this should be the same list as "targets" in ../rust-toolchain.toml
platforms = [
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
"x86_64-unknown-linux-musl", # remote server
]
[traversal-excludes]
workspace-members = [
"remote_server",
]
third-party = [
{ name = "reqwest", version = "0.11.27" },
]
[final-excludes]
workspace-members = [
"zed_extension_api",
# exclude all extensions
"zed_emmet",
"zed_glsl",
"zed_html",
"perplexity",
"zed_proto",
"zed_ruff",
"slash_commands_example",
"zed_snippets",
"zed_test_extension",
"zed_toml",
]

36
.github/ISSUE_TEMPLATE/01_bug_agent.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Bug Report (Agent Panel)
description: Zed Agent Panel Bugs
type: "Bug"
labels: ["agent", "ai"]
title: "Agent Panel: <a short description of the Agent Panel bug>"
body:
- type: textarea
attributes:
label: Summary
description: Describe the bug with a one line summary, and provide detailed reproduction steps
value: |
<!-- Please insert a one line summary of the issue below -->
SUMMARY_SENTENCE_HERE
### Description
<!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
<!-- Please include the LLM provider and model name you are using -->
Steps to trigger the problem:
1.
2.
3.
Actual Behavior:
Expected Behavior:
validations:
required: true
- type: textarea
id: environment
attributes:
label: Zed Version and System Specs
description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
placeholder: |
Output of "zed: Copy System Specs Into Clipboard"
validations:
required: true

View File

@@ -0,0 +1,36 @@
name: Bug Report (Edit Predictions)
description: Zed Edit Predictions bugs
type: "Bug"
labels: ["ai", "inline completion", "zeta"]
title: "Edit Predictions: <a short description of the Edit Prediction bug>"
body:
- type: textarea
attributes:
label: Summary
description: Describe the bug with a one line summary, and provide detailed reproduction steps
value: |
<!-- Please insert a one line summary of the issue below -->
SUMMARY_SENTENCE_HERE
### Description
<!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
<!-- Please include the LLM provider and model name you are using -->
Steps to trigger the problem:
1.
2.
3.
Actual Behavior:
Expected Behavior:
validations:
required: true
- type: textarea
id: environment
attributes:
label: Zed Version and System Specs
description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
placeholder: |
Output of "zed: Copy System Specs Into Clipboard"
validations:
required: true

35
.github/ISSUE_TEMPLATE/03_bug_git.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Bug Report (Git)
description: Zed Git-Related Bugs
type: "Bug"
labels: ["git"]
title: "Git: <a short description of the Git bug>"
body:
- type: textarea
attributes:
label: Summary
description: Describe the bug with a one line summary, and provide detailed reproduction steps
value: |
<!-- Please insert a one line summary of the issue below -->
SUMMARY_SENTENCE_HERE
### Description
<!-- Describe with sufficient detail to reproduce from a clean Zed install. -->
Steps to trigger the problem:
1.
2.
3.
Actual Behavior:
Expected Behavior:
validations:
required: true
- type: textarea
id: environment
attributes:
label: Zed Version and System Specs
description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
placeholder: |
Output of "zed: Copy System Specs Into Clipboard"
validations:
required: true

View File

@@ -0,0 +1,56 @@
name: Bug Report (Other)
description: |
Something else is broken in Zed (exclude crashing).
type: "Bug"
body:
- type: textarea
attributes:
label: Summary
description: Provide a one sentence summary and detailed reproduction steps
value: |
<!-- Begin your issue with a one sentence summary -->
SUMMARY_SENTENCE_HERE
### Description
<!-- Describe with sufficient detail to reproduce from a clean Zed install.
- Any code must be sufficient to reproduce (include context!)
- Code must as text, not just as a screenshot.
- Issues with insufficient detail may be summarily closed.
-->
Steps to reproduce:
1.
2.
3.
4.
Expected Behavior:
Actual Behavior:
<!-- Before Submitting, did you:
1. Include settings.json, keymap.json, .editorconfig if relevant?
2. Check your Zed.log for relevant errors? (please include!)
3. Click Preview to ensure everything looks right?
4. Hide videos, large images and logs in ``` inside collapsible blocks:
<details><summary>click to expand</summary>
```json
```
</details>
-->
validations:
required: true
- type: textarea
id: environment
attributes:
label: Zed Version and System Specs
description: |
Open Zed, from the command palette select "zed: Copy System Specs Into Clipboard"
placeholder: |
Output of "zed: Copy System Specs Into Clipboard"
validations:
required: true

View File

@@ -5,10 +5,12 @@ body:
- type: textarea
attributes:
label: Summary
description: Describe the bug with a one line summary, and provide detailed reproduction steps
description: Summarize the issue with detailed reproduction steps
value: |
<!-- Please insert a one line summary of the issue below -->
<!-- Begin your issue with a one sentence summary -->
SUMMARY_SENTENCE_HERE
### Description
<!-- Include all steps necessary to reproduce from a clean Zed installation. Be verbose -->
Steps to trigger the problem:
1.
@@ -16,7 +18,6 @@ body:
3.
Actual Behavior:
Expected Behavior:
validations:
@@ -40,10 +41,11 @@ body:
value: |
<details><summary>Zed.log</summary>
<!-- Click below this line and paste or drag-and-drop your log-->
```
<!-- Paste your log inside the code block. -->
```log
```
<!-- Click above this line and paste or drag-and-drop your log--></details>
</details>
validations:
required: false

View File

@@ -1,57 +0,0 @@
name: Bug Report
description: |
Something is broken in Zed (exclude crashing).
type: "Bug"
body:
- type: textarea
attributes:
label: Summary
description: Describe the bug with a one line summary, and provide detailed reproduction steps
value: |
<!-- Please insert a one line summary of the issue below -->
SUMMARY_SENTENCE_HERE
<!-- Be verbose: Include all steps necessary to reproduce from a clean Zed installation. -->
<!-- Code snippets are better than images, a repository link that reproduces the issue is ideal. -->
Steps to trigger the problem:
1.
2.
3.
4.
Actual Behavior:
Expected Behavior:
<!--
Is there anything additional necessary to reproduce this issue?
- settings.json, keymap.json, .editorconfig etc?
- Does it happen intermittently or only with specific projects / file types?
- Have you found a workaround?
Did you check your Zed.log to see if there is any relevant details there?
- When including large items (videos, screenshots, logs, configs) please wrap with:
<details><summary>See inside for XXXXYYY</summary>
```shell
code
```
</details>
-->
validations:
required: true
- type: textarea
id: environment
attributes:
label: Zed Version and System Specs
description: 'Open Zed, and in the command palette select "zed: Copy System Specs Into Clipboard"'
placeholder: |
Output of "zed: Copy System Specs Into Clipboard"
validations:
required: true

View File

@@ -4,9 +4,6 @@ contact_links:
- name: Feature Request
url: https://github.com/zed-industries/zed/discussions/new/choose
about: To request a feature, open a new Discussion in one of the appropriate Discussion categories
- name: Zed Discussion Forum
url: https://github.com/zed-industries/zed/discussions
about: A community discussion forum
- name: "Zed Discord: #Support Channel"
- name: "Zed Discord"
url: https://zed.dev/community-links
about: Real-time discussion and user support

View File

@@ -23,4 +23,4 @@ runs:
- name: Run tests
shell: pwsh
working-directory: ${{ inputs.working-directory }}
run: cargo nextest run --workspace --no-fail-fast
run: cargo nextest run --workspace --no-fail-fast --config='profile.dev.debug="limited"'

View File

@@ -110,6 +110,39 @@ jobs:
input: "crates/proto/proto/"
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/"
workspace_hack:
timeout-minutes: 60
name: Check workspace-hack crate
needs: [job_spec]
if: |
github.repository_owner == 'zed-industries' &&
needs.job_spec.outputs.run_tests == 'true'
runs-on:
- buildjet-8vcpu-ubuntu-2204
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Add Rust to the PATH
run: echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install cargo-hakari
uses: clechasseur/rs-cargo@8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386 # v2
with:
command: install
args: cargo-hakari@0.9.35
- name: Check workspace-hack Cargo.toml is up-to-date
run: |
cargo hakari generate --diff || {
echo "To fix, run script/update-workspace-hack or script/update-workspace-hack.ps1";
false
}
- name: Check all crates depend on workspace-hack
run: |
cargo hakari manage-deps --dry-run || {
echo "To fix, run script/update-workspace-hack or script/update-workspace-hack.ps1"
false
}
style:
timeout-minutes: 60
name: Check formatting and spelling
@@ -432,6 +465,7 @@ jobs:
- job_spec
- style
- migration_checks
- workspace_hack
- linux_tests
- build_remote_server
- macos_tests
@@ -674,6 +708,51 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
nix-build:
timeout-minutes: 60
name: Nix Build
continue-on-error: true
if: github.repository_owner == 'zed-industries' && contains(github.event.pull_request.labels.*.name, 'run-nix')
strategy:
fail-fast: false
matrix:
system:
- os: x86 Linux
runner: buildjet-16vcpu-ubuntu-2204
install_nix: true
- os: arm Mac
runner: [macOS, ARM64, test]
install_nix: false
runs-on: ${{ matrix.system.runner }}
env:
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON: ${{ secrets.ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON }}
GIT_LFS_SKIP_SMUDGE: 1 # breaks the livekit rust sdk examples which we don't actually depend on
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
- name: Set path
if: ${{ ! matrix.system.install_nix }}
run: |
echo "/nix/var/nix/profiles/default/bin" >> $GITHUB_PATH
echo "/Users/administrator/.nix-profile/bin" >> $GITHUB_PATH
- uses: cachix/install-nix-action@02a151ada4993995686f9ed4f1be7cfbb229e56f # v31
if: ${{ matrix.system.install_nix }}
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
with:
name: zed-industries
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
skipPush: true
- run: nix build .#debug
- name: Limit /nix/store to 50GB
run: "[ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d"
auto-release-preview:
name: Auto release preview
if: |

View File

@@ -216,7 +216,8 @@ jobs:
name: zed-industries
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- run: nix build
- run: nix-collect-garbage -d
- name: Limit /nix/store to 50GB
run: '[ $(du -sm /nix/store | cut -f1) -gt 50000 ] && nix-collect-garbage -d'
update-nightly-tag:
name: Update nightly tag

View File

@@ -1,14 +1,14 @@
[
{
"label": "Debug Zed with LLDB",
"adapter": "lldb",
"adapter": "LLDB",
"program": "$ZED_WORKTREE_ROOT/target/debug/zed",
"request": "launch",
"cwd": "$ZED_WORKTREE_ROOT"
},
{
"label": "Debug Zed with GDB",
"adapter": "gdb",
"adapter": "GDB",
"program": "$ZED_WORKTREE_ROOT/target/debug/zed",
"request": "launch",
"cwd": "$ZED_WORKTREE_ROOT",

2288
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ members = [
"crates/assets",
"crates/assistant",
"crates/assistant_context_editor",
"crates/assistant_eval",
"crates/agent_eval",
"crates/assistant_settings",
"crates/assistant_slash_command",
"crates/assistant_slash_commands",
@@ -192,6 +192,7 @@ members = [
# Tooling
#
"tooling/workspace-hack",
"tooling/xtask",
]
default-members = ["crates/zed"]
@@ -214,7 +215,7 @@ askpass = { path = "crates/askpass" }
assets = { path = "crates/assets" }
assistant = { path = "crates/assistant" }
assistant_context_editor = { path = "crates/assistant_context_editor" }
assistant_eval = { path = "crates/assistant_eval" }
assistant_eval = { path = "crates/agent_eval" }
assistant_settings = { path = "crates/assistant_settings" }
assistant_slash_command = { path = "crates/assistant_slash_command" }
assistant_slash_commands = { path = "crates/assistant_slash_commands" }
@@ -395,14 +396,14 @@ async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "8
async-recursion = "1.0.0"
async-tar = "0.5.0"
async-trait = "0.1"
async-tungstenite = "0.28"
async-tungstenite = "0.29.1"
async-watch = "0.3.1"
async_zip = { version = "0.0.17", features = ["deflate", "deflate64"] }
aws-config = { version = "1.5.16", features = ["behavior-version-latest"] }
aws-credential-types = { version = "1.2.1", features = ["hardcoded-credentials"] }
aws-sdk-bedrockruntime = { version = "1.73.0", features = ["behavior-version-latest"] }
aws-smithy-runtime-api = { version = "1.7.3", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.2.13", features = ["http-body-1-x"] }
aws-config = { version = "1.6.1", features = ["behavior-version-latest"] }
aws-credential-types = { version = "1.2.2", features = ["hardcoded-credentials"] }
aws-sdk-bedrockruntime = { version = "1.80.0", features = ["behavior-version-latest"] }
aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] }
base64 = "0.22"
bitflags = "2.6.0"
blade-graphics = { git = "https://github.com/kvark/blade", rev = "b16f5c7bd873c7126f48c82c39e7ae64602ae74f" }
@@ -424,7 +425,7 @@ core-foundation = "0.10.0"
core-foundation-sys = "0.8.6"
ctor = "0.4.0"
dashmap = "6.0"
dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "bfd4af0" }
dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "be69a016ba710191b9fdded28c8b042af4b617f7" }
derive_more = "0.99.17"
dirs = "4.0"
ec4rs = "1.1"
@@ -452,8 +453,8 @@ indoc = "2"
inventory = "0.3.19"
itertools = "0.14.0"
jsonwebtoken = "9.3"
jupyter-protocol = { version = "0.6.0" }
jupyter-websocket-client = { version = "0.9.0" }
jupyter-protocol = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
jupyter-websocket-client = { git = "https://github.com/ConradIrwin/runtimed" ,rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
libc = "0.2"
libsqlite3-sys = { version = "0.30.1", features = ["bundled"] }
linkify = "0.10.0"
@@ -462,21 +463,22 @@ log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
markup5ever_rcdom = "0.3.0"
mlua = { version = "0.10", features = ["lua54", "vendored", "async", "send"] }
nanoid = "0.4"
nbformat = { version = "0.10.0" }
nbformat = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734" }
nix = "0.29"
objc = "0.2"
open = "5.0.0"
num-format = "0.4.4"
ordered-float = "2.1.1"
palette = { version = "0.7.5", default-features = false, features = ["std"] }
parking_lot = "0.12.1"
pathdiff = "0.2"
pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1abe5cec5ebfbe97ca71746a4cfc7fe89bddf8e0" }
pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "845945b830297a50de0e24020b980a65e4820559" }
postage = { version = "0.5", features = ["futures-traits"] }
pretty_assertions = { version = "1.3.0", features = ["unstable"] }
proc-macro2 = "1.0.93"
@@ -499,7 +501,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f
"stream",
] }
rsa = "0.9.6"
runtimelib = { version = "0.25.0", default-features = false, features = [
runtimelib = { git = "https://github.com/ConradIrwin/runtimed", rev = "7130c804216b6914355d15d0b91ea91f6babd734", default-features = false, features = [
"async-dispatcher-runtime",
] }
rustc-demangle = "0.1.23"
@@ -507,6 +509,7 @@ rust-embed = { version = "8.4", features = ["include-exclude"] }
rustc-hash = "2.1.0"
rustls = { version = "0.23.22" }
rustls-platform-verifier = "0.5.0"
scap = { git = "https://github.com/zed-industries/scap", rev = "08f0a01417505cc0990b9931a37e5120db92e0d0", default-features = false }
schemars = { version = "0.8", features = ["impl_json_schema", "indexmap2"] }
semver = "1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
@@ -546,7 +549,7 @@ time = { version = "0.3", features = [
tiny_http = "0.8"
toml = "0.8"
tokio = { version = "1" }
tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"]}
tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
tower-http = "0.4.4"
tree-sitter = { version = "0.25.3", features = ["wasm"] }
tree-sitter-bash = "0.23"
@@ -578,6 +581,7 @@ unicode-script = "0.5.7"
url = "2.2"
urlencoding = "2.1.2"
uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
walkdir = "2.3"
wasmparser = "0.221"
wasm-encoder = "0.221"
wasmtime = { version = "29", default-features = false, features = [
@@ -590,6 +594,7 @@ wasmtime = { version = "29", default-features = false, features = [
wasmtime-wasi = "29"
which = "6.0.0"
wit-component = "0.221"
workspace-hack = "0.1.0"
zed_llm_client = "0.4"
zstd = "0.11"
metal = "0.29"
@@ -656,13 +661,14 @@ features = [
# TODO livekit https://github.com/RustAudio/cpal/pull/891
[patch.crates-io]
cpal = { git = "https://github.com/zed-industries/cpal", rev = "fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" }
real-async-tls = { git = "https://github.com/zed-industries/async-tls", rev = "1e759a4b5e370f87dc15e40756ac4f8815b61d9d", package = "async-tls" }
notify = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "bbb9ea5ae52b253e095737847e367c30653a2e96" }
# Makes the workspace hack crate refer to the local one, but only when you're building locally
workspace-hack = { path = "tooling/workspace-hack" }
[profile.dev]
split-debuginfo = "unpacked"
debug = "limited"
codegen-units = 16
[profile.dev.package]
@@ -772,4 +778,4 @@ let_underscore_future = "allow"
too_many_arguments = "allow"
[workspace.metadata.cargo-machete]
ignored = ["bindgen", "cbindgen", "prost_build", "serde", "component", "linkme"]
ignored = ["bindgen", "cbindgen", "prost_build", "serde", "component", "linkme", "workspace-hack"]

View File

@@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1.2
FROM rust:1.81-bookworm as builder
FROM rust:1.86-bookworm as builder
WORKDIR app
COPY . .

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@@ -1,3 +1 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 2H6.5C6.5 1.86739 6.44732 1.74021 6.35355 1.64645C6.25979 1.55268 6.13261 1.5 6 1.5V2ZM2 1.5C1.72386 1.5 1.5 1.72386 1.5 2C1.5 2.27614 1.72386 2.5 2 2.5L2 1.5ZM5.5 6C5.5 6.27614 5.72386 6.5 6 6.5C6.27614 6.5 6.5 6.27614 6.5 6H5.5ZM1.64645 5.64645C1.45118 5.84171 1.45118 6.15829 1.64645 6.35355C1.84171 6.54882 2.15829 6.54882 2.35355 6.35355L1.64645 5.64645ZM6 1.5H2L2 2.5H6V1.5ZM5.5 2V6H6.5V2H5.5ZM5.64645 1.64645L1.64645 5.64645L2.35355 6.35355L6.35355 2.35355L5.64645 1.64645Z" fill="white"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>

Before

Width:  |  Height:  |  Size: 608 B

After

Width:  |  Height:  |  Size: 296 B

1
assets/icons/bug_off.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bug-off-icon lucide-bug-off"><path d="M15 7.13V6a3 3 0 0 0-5.14-2.1L8 2"/><path d="M14.12 3.88 16 2"/><path d="M22 13h-4v-2a4 4 0 0 0-4-4h-1.3"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="m2 2 20 20"/><path d="M7.7 7.7A4 4 0 0 0 6 11v3a6 6 0 0 0 11.13 3.13"/><path d="M12 20v-8"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/></svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-off-icon lucide-circle-off"><path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/></svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-forward-icon lucide-forward"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg>

After

Width:  |  Height:  |  Size: 312 B

1
assets/icons/power.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-power-icon lucide-power"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>

After

Width:  |  Height:  |  Size: 294 B

4
assets/icons/send.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.09666 3.02263C3.0567 3.00312 3.01178 2.9961 2.96778 3.0025C2.92377 3.00889 2.88271 3.02839 2.84995 3.05847C2.8172 3.08854 2.79426 3.12778 2.78413 3.17108C2.77401 3.21439 2.77716 3.25973 2.79319 3.30121L4.05638 6.69C4.13088 6.89005 4.13088 7.11022 4.05638 7.31027L2.79363 10.6991C2.77769 10.7405 2.77457 10.7858 2.78469 10.829C2.79481 10.8722 2.8177 10.9114 2.85038 10.9414C2.88306 10.9715 2.92402 10.991 2.96794 10.9975C3.01186 11.0039 3.05671 10.997 3.09666 10.9776L11.0943 7.20097C11.1324 7.18297 11.1645 7.15455 11.187 7.11899C11.2096 7.08344 11.2215 7.04222 11.2215 7.00014C11.2215 6.95805 11.2096 6.91683 11.187 6.88128C11.1645 6.84573 11.1324 6.8173 11.0943 6.79931L3.09666 3.02263Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.11255 7.00014H11.2216" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1014 B

View File

@@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 9.8V4.2C4 4.08954 4.08954 4 4.2 4H9.8C9.91046 4 10 4.08954 10 4.2V9.8C10 9.91046 9.91046 10 9.8 10H4.2C4.08954 10 4 9.91046 4 9.8Z" fill="#C56757" stroke="#C56757" stroke-width="1.25" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -311,6 +311,7 @@
"ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
"ctrl-k u": ["pane::CloseCleanItems", { "close_pinned": false }],
"ctrl-k w": ["pane::CloseAllItems", { "close_pinned": false }],
"ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes",
"back": "pane::GoBack",
"ctrl-alt--": "pane::GoBack",
"ctrl-alt-_": "pane::GoForward",
@@ -481,6 +482,8 @@
"alt-shift-r": ["task::Spawn", { "reveal_target": "center" }]
// also possible to spawn tasks by name:
// "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
// or by tag:
// "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
}
},
{
@@ -621,7 +624,6 @@
"context": "AgentPanel",
"bindings": {
"ctrl-n": "agent::NewThread",
"new": "agent::NewThread",
"ctrl-alt-n": "agent::NewPromptEditor",
"ctrl-shift-h": "agent::OpenHistory",
"ctrl-alt-c": "agent::OpenConfiguration",
@@ -632,6 +634,13 @@
"ctrl-alt-e": "agent::RemoveAllContext"
}
},
{
"context": "AgentPanel > Markdown",
"bindings": {
"copy": "markdown::CopyAsMarkdown",
"ctrl-c": "markdown::CopyAsMarkdown"
}
},
{
"context": "AgentPanel && prompt_editor",
"use_key_equivalents": true,
@@ -657,6 +666,15 @@
"alt-enter": "editor::Newline"
}
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline"
}
},
{
"context": "ContextStrip",
"bindings": {

View File

@@ -291,6 +291,13 @@
"cmd-alt-e": "agent::RemoveAllContext"
}
},
{
"context": "AgentPanel > Markdown",
"use_key_equivalents": true,
"bindings": {
"cmd-c": "markdown::CopyAsMarkdown"
}
},
{
"context": "AgentPanel && prompt_editor",
"use_key_equivalents": true,
@@ -317,6 +324,15 @@
"alt-enter": "editor::Newline"
}
},
{
"context": "AgentFeedbackMessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"escape": "menu::Cancel",
"enter": "menu::Confirm",
"alt-enter": "editor::Newline"
}
},
{
"context": "ContextStrip",
"use_key_equivalents": true,
@@ -329,10 +345,28 @@
"enter": "agent::AcceptSuggestedContext"
}
},
{
"context": "AgentConfiguration",
"bindings": {
"ctrl--": "pane::GoBack"
}
},
{
"context": "ThreadHistory",
"bindings": {
"backspace": "agent::RemoveSelectedThread"
"ctrl--": "pane::GoBack"
}
},
{
"context": "ThreadHistory",
"bindings": {
"ctrl--": "pane::GoBack"
}
},
{
"context": "ThreadHistory > Editor",
"bindings": {
"shift-backspace": "agent::RemoveSelectedThread"
}
},
{
@@ -421,7 +455,8 @@
"cmd-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
"cmd-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
"cmd-k u": ["pane::CloseCleanItems", { "close_pinned": false }],
"cmd-k cmd-w": ["pane::CloseAllItems", { "close_pinned": false }],
"cmd-k w": ["pane::CloseAllItems", { "close_pinned": false }],
"cmd-k cmd-w": "workspace::CloseAllItemsAndPanes",
"cmd-f": "project_search::ToggleFocus",
"cmd-g": "search::SelectNextMatch",
"cmd-shift-g": "search::SelectPreviousMatch",
@@ -490,7 +525,7 @@
"cmd-k cmd-9": ["editor::FoldAtLevel", 9],
"cmd-k cmd-0": "editor::FoldAll",
"cmd-k cmd-j": "editor::UnfoldAll",
// Using `ctrl-space` in Zed requires disabling the macOS global shortcut.
// Using `ctrl-space` / `ctrl-shift-space` in Zed requires disabling the macOS global shortcut.
// System Preferences->Keyboard->Keyboard Shortcuts->Input Sources->Select the previous input source (uncheck)
"ctrl-space": "editor::ShowCompletions",
"ctrl-shift-space": "editor::ShowWordCompletions",
@@ -598,6 +633,8 @@
"ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }]
// also possible to spawn tasks by name:
// "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
// or by tag:
// "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
}
},
// Bindings from Sublime Text
@@ -966,6 +1003,8 @@
"cmd-home": "terminal::ScrollToTop",
"shift-end": "terminal::ScrollToBottom",
"cmd-end": "terminal::ScrollToBottom",
// Using `ctrl-shift-space` in Zed requires disabling the macOS global shortcut.
// System Preferences->Keyboard->Keyboard Shortcuts->Input Sources->Select the previous input source (uncheck)
"ctrl-shift-space": "terminal::ToggleViMode",
"ctrl-k up": "pane::SplitUp",
"ctrl-k down": "pane::SplitDown",

View File

@@ -44,6 +44,12 @@
"[ /": "vim::PreviousComment",
"] *": "vim::NextComment",
"] /": "vim::NextComment",
"[ -": "vim::PreviousLesserIndent",
"[ +": "vim::PreviousGreaterIndent",
"[ =": "vim::PreviousSameIndent",
"] -": "vim::NextLesserIndent",
"] +": "vim::NextGreaterIndent",
"] =": "vim::NextSameIndent",
// Word motions
"w": "vim::NextWordStart",
"e": "vim::NextWordEnd",
@@ -335,27 +341,106 @@
}
},
{
"context": "vim_mode == helix_normal",
"context": "vim_mode == helix_normal && !menu",
"bindings": {
"escape": "editor::Cancel",
"ctrl-[": "editor::Cancel",
":": "command_palette::Toggle",
"shift-d": "vim::DeleteToEndOfLine",
"shift-j": "vim::JoinLines",
"y": "editor::Copy",
"shift-y": "vim::YankLine",
"i": "vim::InsertBefore",
"shift-i": "vim::InsertFirstNonWhitespace",
"a": "vim::InsertAfter",
"d": "vim::HelixDelete",
"w": "vim::NextWordStart",
"e": "vim::NextWordEnd",
"b": "vim::PreviousWordStart",
"shift-a": "vim::InsertEndOfLine",
"o": "vim::InsertLineBelow",
"shift-o": "vim::InsertLineAbove",
"~": "vim::ChangeCase",
"ctrl-a": "vim::Increment",
"ctrl-x": "vim::Decrement",
"p": "vim::Paste",
"shift-p": ["vim::Paste", { "before": true }],
"u": "vim::Undo",
"ctrl-r": "vim::Redo",
"r": "vim::PushReplace",
"s": "vim::Substitute",
"shift-s": "vim::SubstituteLine",
">": "vim::Indent",
"<": "vim::Outdent",
"=": "vim::AutoIndent",
"g u": "vim::PushLowercase",
"g shift-u": "vim::PushUppercase",
"g ~": "vim::PushOppositeCase",
"\"": "vim::PushRegister",
"g q": "vim::PushRewrap",
"g w": "vim::PushRewrap",
"ctrl-pagedown": "pane::ActivateNextItem",
"ctrl-pageup": "pane::ActivatePreviousItem",
"insert": "vim::InsertBefore",
// tree-sitter related commands
"[ x": "editor::SelectLargerSyntaxNode",
"] x": "editor::SelectSmallerSyntaxNode",
"] d": "editor::GoToDiagnostic",
"[ d": "editor::GoToPreviousDiagnostic",
"] c": "editor::GoToHunk",
"[ c": "editor::GoToPreviousHunk",
// Goto mode
"g n": "pane::ActivateNextItem",
"g p": "pane::ActivatePreviousItem",
// "tab": "pane::ActivateNextItem",
// "shift-tab": "pane::ActivatePrevItem",
"shift-h": "pane::ActivatePreviousItem",
"shift-l": "pane::ActivateNextItem",
"g l": "vim::EndOfLine",
"g h": "vim::StartOfLine",
"g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s"
"g e": "vim::EndOfDocument",
"g y": "editor::GoToTypeDefinition",
"g r": "editor::FindAllReferences", // zed specific
"g t": "vim::WindowTop",
"g c": "vim::WindowMiddle",
"g b": "vim::WindowBottom",
"h": "vim::Left",
"j": "vim::Down",
"k": "vim::Up",
"l": "vim::Right"
"x": "editor::SelectLine",
"shift-x": "editor::SelectLine",
// Window mode
"space w h": "workspace::ActivatePaneLeft",
"space w l": "workspace::ActivatePaneRight",
"space w k": "workspace::ActivatePaneUp",
"space w j": "workspace::ActivatePaneDown",
"space w q": "pane::CloseActiveItem",
"space w s": "pane::SplitRight",
"space w r": "pane::SplitRight",
"space w v": "pane::SplitDown",
"space w d": "pane::SplitDown",
// Space mode
"space f": "file_finder::Toggle",
"space k": "editor::Hover",
"space s": "outline::Toggle",
"space shift-s": "project_symbols::Toggle",
"space d": "editor::GoToDiagnostic",
"space r": "editor::Rename",
"space a": "editor::ToggleCodeActions",
"space h": "editor::SelectAllMatches",
"space c": "editor::ToggleComments",
"space y": "editor::Copy",
"space p": "editor::Paste",
// Match mode
"m m": "vim::Matching",
"m i w": ["workspace::SendKeystrokes", "v i w"],
"shift-u": "editor::Redo",
"ctrl-c": "editor::ToggleComments",
"d": "vim::HelixDelete",
"c": "vim::Substitute",
"shift-c": "editor::AddSelectionBelow"
}
},
{
"context": "vim_mode == insert && !(showing_code_actions || showing_completions)",
"bindings": {
"ctrl-p": "editor::ShowCompletions",
"ctrl-n": "editor::ShowCompletions"
"ctrl-p": "editor::ShowWordCompletions",
"ctrl-n": "editor::ShowWordCompletions"
}
},
{

View File

@@ -6,15 +6,26 @@ You are an AI assistant integrated into a code editor. You have the programming
It will be up to you to decide which of these you are doing based on what the user has told you. When unclear, ask clarifying questions to understand the user's intent before proceeding.
You should only perform actions that modify the user's system if explicitly requested by the user:
- If the user asks a question about how to accomplish a task, provide guidance or information, and use read-only tools (e.g., search) to assist. You may suggest potential actions, but do not directly modify the users system without explicit instruction.
- If the user asks a question about how to accomplish a task, provide guidance or information, and use read-only tools (e.g., search) to assist. You may suggest potential actions, but do not directly modify the user's system without explicit instruction.
- If the user clearly requests that you perform an action, carry out the action directly without explaining why you are doing so.
When answering questions, it's okay to give incomplete examples containing comments about what would go there in a real version. When being asked to directly perform tasks on the code base, you must ALWAYS make fully working code. You may never "simplify" the code by omitting or deleting functionality you know the user has requested, and you must NEVER write comments like "in a full version, this would..." - instead, you must actually implement the real version. Don't be lazy!
Note that project files are automatically backed up. The user can always get them back later if anything goes wrong, so there's
no need to create backup files (e.g. `.bak` files) because these files will just take up unnecessary space on the user's disk.
When attempting to resolve issues around failing tests, never simply remove the failing tests. Unless the user explicitly asks you to remove tests, ALWAYS attempt to fix the code causing the tests to fail.
Ignore "TODO"-type comments unless they're relevant to the user's explicit request or the user specifically asks you to address them. It is, however, okay to include them in codebase summaries.
<style>
Editing code:
- Make sure to take previous edits into account.
- The edits you perform might lead to errors or warnings. At the end of your changes, check whether you introduced any problems, and fix them before providing a summary of the changes you made.
- You may only attempt to fix these up to 3 times. If you have tried 3 times to fix them, and there are still problems remaining, you must not continue trying to fix them, and must instead tell the user that there are problems remaining - and ask if the user would like you to attempt to solve them further.
- Do not fix errors unrelated to your changes unless the user explicitly asks you to do so.
- Prefer to move files over recreating them. The move can be followed by minor edits if required.
- If you seem to be stuck, never go back and "simplify the implementation" by deleting the parts of the implementation you're stuck on and replacing them with comments. If you ever feel the urge to do this, instead immediately stop whatever you're doing (even if the code is in a broken state), report that you are stuck, explain what you're stuck on, and ask the user how to proceed.
Tool use:
- Make sure to adhere to the tools schema.
@@ -33,6 +44,106 @@ Responding:
For example, don't say "Now I'm going to check diagnostics to see if there are any warnings or errors," followed by running a tool which checks diagnostics and reports warnings or errors; instead, just request the tool call without saying anything.
- All tool results are provided to you automatically, so DO NOT thank the user when this happens.
Whenever you mention a code block, you MUST use ONLY the following format:
```language path/to/Something.blah#L123-456
(code goes here)
```
The `#L123-456` means the line number range 123 through 456, and the path/to/Something.blah
is a path in the project. (If there is no valid path in the project, then you can use
/dev/null/path.extension for its path.) This is the ONLY valid way to format code blocks, because the Markdown parser
does not understand the more common ```language syntax, or bare ``` blocks. It only
understands this path-based syntax, and if the path is missing, then it will error and you will have to do it over again.
Just to be really clear about this, if you ever find yourself writing three backticks followed by a language name, STOP!
You have made a mistake. You can only ever put paths after triple backticks!
<example>
Based on all the information I've gathered, here's a summary of how this system works:
1. The README file is loaded into the system.
2. The system finds the first two headers, including everything in between. In this case, that would be:
```path/to/README.md#L8-12
# First Header
This is the info under the first header.
## Sub-header
```
3. Then the system finds the last header in the README:
```path/to/README.md#L27-29
## Last Header
This is the last header in the README.
```
4. Finally, it passes this information on to the next process.
</example>
<example>
In Markdown, hash marks signify headings. For example:
```/dev/null/example.md#L1-3
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</example>
Here are examples of ways you must never render code blocks:
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
```
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</bad_example_do_not_do_this>
This example is unacceptable because it does not include the path.
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
```markdown
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</bad_example_do_not_do_this>
This example is unacceptable because it has the language instead of the path.
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
# Level 1 heading
## Level 2 heading
### Level 3 heading
</bad_example_do_not_do_this>
This example is unacceptable because it uses indentation to mark the code block
instead of backticks with a path.
<bad_example_do_not_do_this>
In Markdown, hash marks signify headings. For example:
```markdown
/dev/null/example.md#L1-3
# Level 1 heading
## Level 2 heading
### Level 3 heading
```
</bad_example_do_not_do_this>
This example is unacceptable because the path is in the wrong place. The path must be directly after the opening backticks.
</style>
The user has opened a project that contains the following root directories/files. Whenever you specify a path in the project, it must be a relative path which begins with one of these root directories/files:
{{#each worktrees}}

View File

@@ -1,8 +0,0 @@
A software developer is asking a question about their project. The source files in their project have been indexed into a database of semantic text embeddings.
Your task is to generate a list of 4 diverse search queries that can be run on this embedding database, in order to retrieve a list of code snippets
that are relevant to the developer's question. Redundant search queries will be heavily penalized, so only include another query if it's sufficiently
distinct from previous ones.
Here is the question that's been asked, together with context that the developer has added manually:
{{{context_buffer}}}

View File

@@ -644,7 +644,7 @@
"tools": {
"diagnostics": true,
"fetch": true,
"list_directory": true,
"list_directory": false,
"now": true,
"path_search": true,
"read_file": true,
@@ -659,15 +659,14 @@
"bash": true,
"batch_tool": true,
"code_symbols": true,
"copy_path": true,
"copy_path": false,
"create_file": true,
"delete_path": true,
"delete_path": false,
"diagnostics": true,
"find_replace_file": true,
"edit_files": false,
"fetch": true,
"list_directory": true,
"move_path": true,
"list_directory": false,
"move_path": false,
"now": true,
"path_search": true,
"read_file": true,
@@ -1201,7 +1200,27 @@
// When set to 0, waits indefinitely.
//
// Default: 0
"lsp_fetch_timeout_ms": 0
"lsp_fetch_timeout_ms": 0,
// Controls what range to replace when accepting LSP completions.
//
// When LSP servers give an `InsertReplaceEdit` completion, they provides two ranges: `insert` and `replace`. Usually, `insert`
// contains the word prefix before your cursor and `replace` contains the whole word.
//
// Effectively, this setting just changes whether Zed will use the received range for `insert` or `replace`, so the results may
// differ depending on the underlying LSP server.
//
// Possible values:
// 1. "insert"
// Replaces text before the cursor, using the `insert` range described in the LSP specification.
// 2. "replace"
// Replaces text before and after the cursor, using the `replace` range described in the LSP specification.
// 3. "replace_subsequence"
// Behaves like `"replace"` if the text that would be replaced is a subsequence of the completion text,
// and like `"insert"` otherwise.
// 4. "replace_suffix"
// Behaves like `"replace"` if the text after the cursor is a suffix of the completion, and like
// `"insert"` otherwise.
"lsp_insert_mode": "replace_suffix"
},
// Different settings for specific languages.
"languages": {

View File

@@ -43,6 +43,8 @@
// "args": ["--login"]
// }
// }
"shell": "system"
"shell": "system",
// Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
"tags": []
}
]

View File

@@ -87,9 +87,9 @@
"terminal.ansi.blue": "#83a598ff",
"terminal.ansi.bright_blue": "#414f4aff",
"terminal.ansi.dim_blue": "#c0d2cbff",
"terminal.ansi.magenta": "#a89984ff",
"terminal.ansi.bright_magenta": "#514a41ff",
"terminal.ansi.dim_magenta": "#d2cabfff",
"terminal.ansi.magenta": "#d3869bff",
"terminal.ansi.bright_magenta": "#8e5868ff",
"terminal.ansi.dim_magenta": "#ff9ebbff",
"terminal.ansi.cyan": "#8ec07cff",
"terminal.ansi.bright_cyan": "#45603eff",
"terminal.ansi.dim_cyan": "#c7dfbdff",
@@ -472,9 +472,9 @@
"terminal.ansi.blue": "#83a598ff",
"terminal.ansi.bright_blue": "#414f4aff",
"terminal.ansi.dim_blue": "#c0d2cbff",
"terminal.ansi.magenta": "#a89984ff",
"terminal.ansi.bright_magenta": "#514a41ff",
"terminal.ansi.dim_magenta": "#d2cabfff",
"terminal.ansi.magenta": "#d3869bff",
"terminal.ansi.bright_magenta": "#8e5868ff",
"terminal.ansi.dim_magenta": "#ff9ebbff",
"terminal.ansi.cyan": "#8ec07cff",
"terminal.ansi.bright_cyan": "#45603eff",
"terminal.ansi.dim_cyan": "#c7dfbdff",
@@ -857,9 +857,9 @@
"terminal.ansi.blue": "#83a598ff",
"terminal.ansi.bright_blue": "#414f4aff",
"terminal.ansi.dim_blue": "#c0d2cbff",
"terminal.ansi.magenta": "#a89984ff",
"terminal.ansi.bright_magenta": "#514a41ff",
"terminal.ansi.dim_magenta": "#d2cabfff",
"terminal.ansi.magenta": "#d3869bff",
"terminal.ansi.bright_magenta": "#8e5868ff",
"terminal.ansi.dim_magenta": "#ff9ebbff",
"terminal.ansi.cyan": "#8ec07cff",
"terminal.ansi.bright_cyan": "#45603eff",
"terminal.ansi.dim_cyan": "#c7dfbdff",
@@ -1242,9 +1242,9 @@
"terminal.ansi.blue": "#0b6678ff",
"terminal.ansi.bright_blue": "#8fb0baff",
"terminal.ansi.dim_blue": "#14333bff",
"terminal.ansi.magenta": "#7c6f64ff",
"terminal.ansi.bright_magenta": "#bcb5afff",
"terminal.ansi.dim_magenta": "#3e3833ff",
"terminal.ansi.magenta": "#8f3e71ff",
"terminal.ansi.bright_magenta": "#c76da0ff",
"terminal.ansi.dim_magenta": "#5c2848ff",
"terminal.ansi.cyan": "#437b59ff",
"terminal.ansi.bright_cyan": "#9fbca8ff",
"terminal.ansi.dim_cyan": "#253e2eff",
@@ -1627,9 +1627,9 @@
"terminal.ansi.blue": "#0b6678ff",
"terminal.ansi.bright_blue": "#8fb0baff",
"terminal.ansi.dim_blue": "#14333bff",
"terminal.ansi.magenta": "#7c6f64ff",
"terminal.ansi.bright_magenta": "#bcb5afff",
"terminal.ansi.dim_magenta": "#3e3833ff",
"terminal.ansi.magenta": "#8f3e71ff",
"terminal.ansi.bright_magenta": "#c76da0ff",
"terminal.ansi.dim_magenta": "#5c2848ff",
"terminal.ansi.cyan": "#437b59ff",
"terminal.ansi.bright_cyan": "#9fbca8ff",
"terminal.ansi.dim_cyan": "#253e2eff",
@@ -2012,9 +2012,9 @@
"terminal.ansi.blue": "#0b6678ff",
"terminal.ansi.bright_blue": "#8fb0baff",
"terminal.ansi.dim_blue": "#14333bff",
"terminal.ansi.magenta": "#7c6f64ff",
"terminal.ansi.bright_magenta": "#bcb5afff",
"terminal.ansi.dim_magenta": "#3e3833ff",
"terminal.ansi.magenta": "#8f3e71ff",
"terminal.ansi.bright_magenta": "#c76da0ff",
"terminal.ansi.dim_magenta": "#5c2848ff",
"terminal.ansi.cyan": "#437b59ff",
"terminal.ansi.bright_cyan": "#9fbca8ff",
"terminal.ansi.dim_cyan": "#253e2eff",

View File

@@ -25,6 +25,7 @@ smallvec.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }

View File

@@ -11,13 +11,22 @@ use language::{BinaryStatus, LanguageRegistry, LanguageServerId};
use project::{
EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project,
ProjectEnvironmentEvent,
git_store::{GitStoreEvent, Repository},
};
use smallvec::SmallVec;
use std::{cmp::Reverse, fmt::Write, path::Path, sync::Arc, time::Duration};
use std::{
cmp::Reverse,
fmt::Write,
path::Path,
sync::Arc,
time::{Duration, Instant},
};
use ui::{ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
use util::truncate_and_trailoff;
use workspace::{StatusItemView, Workspace, item::ItemHandle};
const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0);
actions!(activity_indicator, [ShowErrorMessage]);
pub enum Event {
@@ -105,6 +114,15 @@ impl ActivityIndicator {
)
.detach();
cx.subscribe(
&project.read(cx).git_store().clone(),
|_, _, event: &GitStoreEvent, cx| match event {
project::git_store::GitStoreEvent::JobsUpdated => cx.notify(),
_ => {}
},
)
.detach();
if let Some(auto_updater) = auto_updater.as_ref() {
cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
}
@@ -285,6 +303,34 @@ impl ActivityIndicator {
});
}
let current_job = self
.project
.read(cx)
.active_repository(cx)
.map(|r| r.read(cx))
.and_then(Repository::current_job);
// Show any long-running git command
if let Some(job_info) = current_job {
if Instant::now() - job_info.start >= GIT_OPERATION_DELAY {
return Some(Content {
icon: Some(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
)
.into_any_element(),
),
message: job_info.message.into(),
on_click: None,
});
}
}
// Show any language server installation info.
let mut downloading = SmallVec::<[_; 3]>::new();
let mut checking_for_update = SmallVec::<[_; 3]>::new();

View File

@@ -81,11 +81,12 @@ theme.workspace = true
time.workspace = true
time_format.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
uuid.workspace = true
vim_mode_setting.workspace = true
workspace.workspace = true
zed_actions.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
buffer_diff = { workspace = true, features = ["test-support"] }

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
use crate::{Thread, ThreadEvent};
use crate::{Keep, Reject, Thread, ThreadEvent};
use anyhow::Result;
use buffer_diff::DiffHunkStatus;
use collections::HashSet;
use editor::{
AnchorRangeExt, Direction, Editor, EditorEvent, MultiBuffer, ToPoint,
Direction, Editor, EditorEvent, MultiBuffer, ToPoint,
actions::{GoToHunk, GoToPreviousHunk},
scroll::Autoscroll,
};
@@ -26,6 +26,7 @@ use workspace::{
item::{BreadcrumbText, ItemEvent, TabContentParams},
searchable::SearchableItemHandle,
};
use zed_actions::assistant::ToggleFocus;
pub struct AgentDiff {
multibuffer: Entity<MultiBuffer>,
@@ -43,22 +44,29 @@ impl AgentDiff {
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Result<()> {
let existing_diff = workspace.update(cx, |workspace, cx| {
workspace
.items_of_type::<AgentDiff>(cx)
.find(|diff| diff.read(cx).thread == thread)
})?;
) -> Result<Entity<Self>> {
workspace.update(cx, |workspace, cx| {
Self::deploy_in_workspace(thread, workspace, window, cx)
})
}
pub fn deploy_in_workspace(
thread: Entity<Thread>,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<Self> {
let existing_diff = workspace
.items_of_type::<AgentDiff>(cx)
.find(|diff| diff.read(cx).thread == thread);
if let Some(existing_diff) = existing_diff {
workspace.update(cx, |workspace, cx| {
workspace.activate_item(&existing_diff, true, true, window, cx);
})
workspace.activate_item(&existing_diff, true, true, window, cx);
existing_diff
} else {
let agent_diff =
cx.new(|cx| AgentDiff::new(thread.clone(), workspace.clone(), window, cx));
workspace.update(cx, |workspace, cx| {
workspace.add_item_to_center(Box::new(agent_diff), window, cx);
})
cx.new(|cx| AgentDiff::new(thread.clone(), workspace.weak_handle(), window, cx));
workspace.add_item_to_center(Box::new(agent_diff.clone()), window, cx);
agent_diff
}
}
@@ -133,11 +141,11 @@ impl AgentDiff {
let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
for (buffer, diff_handle) in changed_buffers {
let Some(file) = buffer.read(cx).file().cloned() else {
if buffer.read(cx).file().is_none() {
continue;
};
}
let path_key = PathKey::namespaced(0, file.full_path(cx).into());
let path_key = PathKey::for_buffer(&buffer, cx);
paths_to_delete.remove(&path_key);
let snapshot = buffer.read(cx).snapshot();
@@ -235,11 +243,31 @@ impl AgentDiff {
fn handle_thread_event(&mut self, event: &ThreadEvent, cx: &mut Context<Self>) {
match event {
ThreadEvent::SummaryChanged => self.update_title(cx),
ThreadEvent::SummaryGenerated => self.update_title(cx),
_ => {}
}
}
pub fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
self.editor.update(cx, |editor, cx| {
let first_hunk = editor
.diff_hunks_in_ranges(
&[position..editor::Anchor::max()],
&self.multibuffer.read(cx).read(cx),
)
.next();
if let Some(first_hunk) = first_hunk {
let first_hunk_start = first_hunk.multi_buffer_range().start;
editor.change_selections(Some(Autoscroll::fit()), window, cx, |selections| {
selections.select_anchor_ranges([first_hunk_start..first_hunk_start]);
})
}
});
}
}
fn keep(&mut self, _: &crate::Keep, window: &mut Window, cx: &mut Context<Self>) {
let ranges = self
.editor
@@ -327,13 +355,16 @@ impl AgentDiff {
self.update_selection(&diff_hunks_in_ranges, window, cx);
}
let point_ranges = ranges
.into_iter()
.map(|range| range.to_point(&snapshot))
.collect();
self.editor.update(cx, |editor, cx| {
editor.restore_hunks_in_ranges(point_ranges, window, cx)
});
for hunk in &diff_hunks_in_ranges {
let buffer = self.multibuffer.read(cx).buffer(hunk.buffer_id);
if let Some(buffer) = buffer {
self.thread
.update(cx, |thread, cx| {
thread.reject_edits_in_range(buffer, hunk.buffer_range.clone(), cx)
})
.detach_and_log_err(cx);
}
}
}
fn update_selection(
@@ -553,11 +584,12 @@ impl Item for AgentDiff {
}
impl Render for AgentDiff {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_empty = self.multibuffer.read(cx).is_empty();
let focus_handle = &self.focus_handle;
div()
.track_focus(&self.focus_handle)
.track_focus(focus_handle)
.key_context(if is_empty { "EmptyPane" } else { "AgentDiff" })
.on_action(cx.listener(Self::keep))
.on_action(cx.listener(Self::reject))
@@ -568,7 +600,32 @@ impl Render for AgentDiff {
.items_center()
.justify_center()
.size_full()
.when(is_empty, |el| el.child("No changes to review"))
.when(is_empty, |el| {
el.child(
v_flex()
.items_center()
.gap_2()
.child("No changes to review")
.child(
Button::new("continue-iterating", "Continue Iterating")
.style(ButtonStyle::Filled)
.icon(IconName::ForwardArrow)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.full_width()
.key_binding(KeyBinding::for_action_in(
&ToggleFocus,
&focus_handle.clone(),
window,
cx,
))
.on_click(|_event, window, cx| {
window.dispatch_action(ToggleFocus.boxed_clone(), cx)
}),
),
)
})
.when(!is_empty, |el| el.child(self.editor.clone()))
}
}
@@ -604,7 +661,7 @@ fn render_diff_hunk_controls(
.disabled(is_created_file)
.key_binding(
KeyBinding::for_action_in(
&crate::Reject,
&Reject,
&editor.read(cx).focus_handle(cx),
window,
cx,
@@ -625,13 +682,8 @@ fn render_diff_hunk_controls(
}),
Button::new(("keep", row as u64), "Keep")
.key_binding(
KeyBinding::for_action_in(
&crate::Keep,
&editor.read(cx).focus_handle(cx),
window,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
KeyBinding::for_action_in(&Keep, &editor.read(cx).focus_handle(cx), window, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click({
let agent_diff = agent_diff.clone();
@@ -740,15 +792,11 @@ impl editor::Addon for AgentDiffAddon {
pub struct AgentDiffToolbar {
agent_diff: Option<WeakEntity<AgentDiff>>,
_workspace: WeakEntity<Workspace>,
}
impl AgentDiffToolbar {
pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
Self {
agent_diff: None,
_workspace: workspace.weak_handle(),
}
pub fn new() -> Self {
Self { agent_diff: None }
}
fn agent_diff(&self, _: &App) -> Option<Entity<AgentDiff>> {
@@ -942,7 +990,7 @@ mod tests {
Point::new(3, 0)..Point::new(3, 0)
);
// Restoring a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
// Rejecting a hunk also moves the cursor to the next hunk, possibly cycling if it's at the end.
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(None, window, cx, |selections| {
selections.select_ranges([Point::new(10, 0)..Point::new(10, 0)])

View File

@@ -51,7 +51,6 @@ actions!(
ToggleProfileSelector,
RemoveAllContext,
OpenHistory,
OpenConfiguration,
AddContextServer,
RemoveSelectedThread,
Chat,

View File

@@ -409,6 +409,7 @@ impl Render for AssistantConfiguration {
v_flex()
.id("assistant-configuration")
.key_context("AgentConfiguration")
.track_focus(&self.focus_handle(cx))
.bg(cx.theme().colors().panel_background)
.size_full()

View File

@@ -1,17 +1,17 @@
use context_server::{ContextServerSettings, ServerCommand, ServerConfig};
use editor::Editor;
use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*};
use serde_json::json;
use settings::update_settings_file;
use ui::{Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
use ui_input::SingleLineInput;
use workspace::{ModalView, Workspace};
use crate::AddContextServer;
pub struct AddContextServerModal {
workspace: WeakEntity<Workspace>,
name_editor: Entity<Editor>,
command_editor: Entity<Editor>,
name_editor: Entity<SingleLineInput>,
command_editor: Entity<SingleLineInput>,
}
impl AddContextServerModal {
@@ -33,15 +33,10 @@ impl AddContextServerModal {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let name_editor = cx.new(|cx| Editor::single_line(window, cx));
let command_editor = cx.new(|cx| Editor::single_line(window, cx));
name_editor.update(cx, |editor, cx| {
editor.set_placeholder_text("Context server name", cx);
});
command_editor.update(cx, |editor, cx| {
editor.set_placeholder_text("Command to run the context server", cx);
let name_editor =
cx.new(|cx| SingleLineInput::new(window, cx, "Your server name").label("Name"));
let command_editor = cx.new(|cx| {
SingleLineInput::new(window, cx, "Command").label("Command to run the context server")
});
Self {
@@ -52,8 +47,22 @@ impl AddContextServerModal {
}
fn confirm(&mut self, cx: &mut Context<Self>) {
let name = self.name_editor.read(cx).text(cx).trim().to_string();
let command = self.command_editor.read(cx).text(cx).trim().to_string();
let name = self
.name_editor
.read(cx)
.editor()
.read(cx)
.text(cx)
.trim()
.to_string();
let command = self
.command_editor
.read(cx)
.editor()
.read(cx)
.text(cx)
.trim()
.to_string();
if name.is_empty() || command.is_empty() {
return;
@@ -104,8 +113,8 @@ impl EventEmitter<DismissEvent> for AddContextServerModal {}
impl Render for AddContextServerModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_name_empty = self.name_editor.read(cx).text(cx).trim().is_empty();
let is_command_empty = self.command_editor.read(cx).text(cx).trim().is_empty();
let is_name_empty = self.name_editor.read(cx).is_empty(cx);
let is_command_empty = self.command_editor.read(cx).is_empty(cx);
div()
.elevation_3(cx)
@@ -122,18 +131,8 @@ impl Render for AddContextServerModal {
.header(ModalHeader::new().headline("Add Context Server"))
.section(
Section::new()
.child(
v_flex()
.gap_1()
.child(Label::new("Name"))
.child(self.name_editor.clone()),
)
.child(
v_flex()
.gap_1()
.child(Label::new("Command"))
.child(self.command_editor.clone()),
),
.child(self.name_editor.clone())
.child(self.command_editor.clone()),
)
.footer(
ModalFooter::new()

View File

@@ -202,43 +202,43 @@ impl PickerDelegate for ToolPickerDelegate {
let default_profile = self.profile.clone();
let tool = tool.clone();
move |settings, _cx| match settings {
AssistantSettingsContent::Versioned(VersionedAssistantSettingsContent::V2(
settings,
)) => {
let profiles = settings.profiles.get_or_insert_default();
let profile =
profiles
.entry(profile_id)
.or_insert_with(|| AgentProfileContent {
name: default_profile.name.into(),
tools: default_profile.tools,
enable_all_context_servers: Some(
default_profile.enable_all_context_servers,
),
context_servers: default_profile
.context_servers
.into_iter()
.map(|(server_id, preset)| {
(
server_id,
ContextServerPresetContent {
tools: preset.tools,
},
)
})
.collect(),
});
AssistantSettingsContent::Versioned(boxed) => {
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
let profiles = settings.profiles.get_or_insert_default();
let profile =
profiles
.entry(profile_id)
.or_insert_with(|| AgentProfileContent {
name: default_profile.name.into(),
tools: default_profile.tools,
enable_all_context_servers: Some(
default_profile.enable_all_context_servers,
),
context_servers: default_profile
.context_servers
.into_iter()
.map(|(server_id, preset)| {
(
server_id,
ContextServerPresetContent {
tools: preset.tools,
},
)
})
.collect(),
});
match tool.source {
ToolSource::Native => {
*profile.tools.entry(tool.name).or_default() = is_enabled;
}
ToolSource::ContextServer { id } => {
let preset = profile
.context_servers
.entry(id.clone().into())
.or_default();
*preset.tools.entry(tool.name.clone()).or_default() = is_enabled;
match tool.source {
ToolSource::Native => {
*profile.tools.entry(tool.name).or_default() = is_enabled;
}
ToolSource::ContextServer { id } => {
let preset = profile
.context_servers
.entry(id.clone().into())
.or_default();
*preset.tools.entry(tool.name.clone()).or_default() = is_enabled;
}
}
}
}

View File

@@ -9,10 +9,17 @@ use settings::update_settings_file;
use std::sync::Arc;
use ui::{ButtonLike, PopoverMenuHandle, Tooltip, prelude::*};
#[derive(Clone, Copy)]
pub enum ModelType {
Default,
InlineAssistant,
}
pub struct AssistantModelSelector {
selector: Entity<LanguageModelSelector>,
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
focus_handle: FocusHandle,
model_type: ModelType,
}
impl AssistantModelSelector {
@@ -20,6 +27,7 @@ impl AssistantModelSelector {
fs: Arc<dyn Fs>,
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
focus_handle: FocusHandle,
model_type: ModelType,
window: &mut Window,
cx: &mut App,
) -> Self {
@@ -28,11 +36,32 @@ impl AssistantModelSelector {
let fs = fs.clone();
LanguageModelSelector::new(
move |model, cx| {
update_settings_file::<AssistantSettings>(
fs.clone(),
cx,
move |settings, _cx| settings.set_model(model.clone()),
);
let provider = model.provider_id().0.to_string();
let model_id = model.id().0.to_string();
match model_type {
ModelType::Default => {
update_settings_file::<AssistantSettings>(
fs.clone(),
cx,
move |settings, _cx| {
settings.set_model(model.clone());
},
);
}
ModelType::InlineAssistant => {
update_settings_file::<AssistantSettings>(
fs.clone(),
cx,
move |settings, _cx| {
settings.set_inline_assistant_model(
provider.clone(),
model_id.clone(),
);
},
);
}
}
},
window,
cx,
@@ -40,6 +69,7 @@ impl AssistantModelSelector {
}),
menu_handle,
focus_handle,
model_type,
}
}
@@ -50,10 +80,16 @@ impl AssistantModelSelector {
impl Render for AssistantModelSelector {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let active_model = LanguageModelRegistry::read_global(cx).active_model();
let model_registry = LanguageModelRegistry::read_global(cx);
let model = match self.model_type {
ModelType::Default => model_registry.default_model(),
ModelType::InlineAssistant => model_registry.inline_assistant_model(),
};
let focus_handle = self.focus_handle.clone();
let model_name = match active_model {
Some(model) => model.name().0,
let model_name = match model {
Some(model) => model.model.name().0,
_ => SharedString::from("No model selected"),
};

View File

@@ -1,5 +1,6 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Result, anyhow};
use assistant_context_editor::{
@@ -11,12 +12,12 @@ use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet;
use client::zed_urls;
use editor::{Editor, MultiBuffer};
use editor::{Editor, EditorEvent, MultiBuffer};
use fs::Fs;
use gpui::{
Action, AnyElement, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle,
Focusable, FontWeight, KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity,
action_with_deprecated_aliases, prelude::*,
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, Corner, Entity,
EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, Pixels, Subscription, Task,
UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{LanguageModelProviderTosView, LanguageModelRegistry};
@@ -32,26 +33,21 @@ use ui::{
use util::ResultExt as _;
use workspace::Workspace;
use workspace::dock::{DockPosition, Panel, PanelEvent};
use zed_actions::assistant::ToggleFocus;
use zed_actions::agent::OpenConfiguration;
use zed_actions::assistant::{OpenPromptLibrary, ToggleFocus};
use crate::active_thread::ActiveThread;
use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent};
use crate::history_store::{HistoryEntry, HistoryStore};
use crate::message_editor::MessageEditor;
use crate::thread::{Thread, ThreadError, ThreadId};
use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio};
use crate::thread_history::{PastContext, PastThread, ThreadHistory};
use crate::thread_store::ThreadStore;
use crate::{
AgentDiff, InlineAssistant, NewPromptEditor, NewThread, OpenActiveThreadAsMarkdown,
OpenAgentDiff, OpenConfiguration, OpenHistory, ToggleContextPicker,
OpenAgentDiff, OpenHistory, ThreadEvent, ToggleContextPicker,
};
action_with_deprecated_aliases!(
assistant,
OpenPromptLibrary,
["assistant::DeployPromptLibrary"]
);
pub fn init(cx: &mut App) {
cx.observe_new(
|workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
@@ -91,9 +87,8 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenAgentDiff, window, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
workspace.focus_panel::<AssistantPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.open_agent_diff(&OpenAgentDiff, window, cx);
});
let thread = panel.read(cx).thread.read(cx).thread().clone();
AgentDiff::deploy_in_workspace(thread, workspace, window, cx);
}
});
},
@@ -102,12 +97,72 @@ pub fn init(cx: &mut App) {
}
enum ActiveView {
Thread,
Thread {
change_title_editor: Entity<Editor>,
_subscriptions: Vec<gpui::Subscription>,
},
PromptEditor,
History,
Configuration,
}
impl ActiveView {
pub fn thread(thread: Entity<Thread>, window: &mut Window, cx: &mut App) -> Self {
let summary = thread.read(cx).summary_or_default();
let editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text(summary, window, cx);
editor
});
let subscriptions = vec![
window.subscribe(&editor, cx, {
{
let thread = thread.clone();
move |editor, event, window, cx| match event {
EditorEvent::BufferEdited => {
let new_summary = editor.read(cx).text(cx);
thread.update(cx, |thread, cx| {
thread.set_summary(new_summary, cx);
})
}
EditorEvent::Blurred => {
if editor.read(cx).text(cx).is_empty() {
let summary = thread.read(cx).summary_or_default();
editor.update(cx, |editor, cx| {
editor.set_text(summary, window, cx);
});
}
}
_ => {}
}
}
}),
window.subscribe(&thread, cx, {
let editor = editor.clone();
move |thread, event, window, cx| match event {
ThreadEvent::SummaryGenerated => {
let summary = thread.read(cx).summary_or_default();
editor.update(cx, |editor, cx| {
editor.set_text(summary, window, cx);
})
}
_ => {}
}
}),
];
Self::Thread {
change_title_editor: editor,
_subscriptions: subscriptions,
}
}
}
pub struct AssistantPanel {
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
@@ -115,6 +170,7 @@ pub struct AssistantPanel {
language_registry: Arc<LanguageRegistry>,
thread_store: Entity<ThreadStore>,
thread: Entity<ActiveThread>,
_thread_subscription: Subscription,
message_editor: Entity<MessageEditor>,
context_store: Entity<assistant_context_editor::ContextStore>,
context_editor: Option<Entity<ContextEditor>>,
@@ -122,6 +178,7 @@ pub struct AssistantPanel {
configuration_subscription: Option<Subscription>,
local_timezone: UtcOffset,
active_view: ActiveView,
previous_view: Option<ActiveView>,
history_store: Entity<HistoryStore>,
history: Entity<ThreadHistory>,
assistant_dropdown_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -197,6 +254,15 @@ impl AssistantPanel {
let history_store =
cx.new(|cx| HistoryStore::new(thread_store.clone(), context_store.clone(), cx));
cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
let active_view = ActiveView::thread(thread.clone(), window, cx);
let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
if let ThreadEvent::MessageAdded(_) = &event {
// needed to leave empty state
cx.notify();
}
});
let thread = cx.new(|cx| {
ActiveThread::new(
thread.clone(),
@@ -210,13 +276,14 @@ impl AssistantPanel {
});
Self {
active_view: ActiveView::Thread,
active_view,
workspace,
project: project.clone(),
fs: fs.clone(),
language_registry,
thread_store: thread_store.clone(),
thread,
_thread_subscription: thread_subscription,
message_editor,
context_store,
context_editor: None,
@@ -226,8 +293,9 @@ impl AssistantPanel {
chrono::Local::now().offset().local_minus_utc(),
)
.unwrap(),
previous_view: None,
history_store: history_store.clone(),
history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, cx)),
history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)),
assistant_dropdown_menu_handle: PopoverMenuHandle::default(),
width: None,
height: None,
@@ -271,7 +339,8 @@ impl AssistantPanel {
.thread_store
.update(cx, |this, cx| this.create_thread(cx));
self.active_view = ActiveView::Thread;
let thread_view = ActiveView::thread(thread.clone(), window, cx);
self.set_active_view(thread_view, window, cx);
let message_editor_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
@@ -311,6 +380,14 @@ impl AssistantPanel {
cx,
)
});
self._thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
if let ThreadEvent::MessageAdded(_) = &event {
// needed to leave empty state
cx.notify();
}
});
self.message_editor = cx.new(|cx| {
MessageEditor::new(
self.fs.clone(),
@@ -326,7 +403,7 @@ impl AssistantPanel {
}
fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.active_view = ActiveView::PromptEditor;
self.set_active_view(ActiveView::PromptEditor, window, cx);
let context = self
.context_store
@@ -376,11 +453,16 @@ impl AssistantPanel {
}
fn open_history(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.thread_store
.update(cx, |thread_store, cx| thread_store.reload(cx))
.detach_and_log_err(cx);
self.active_view = ActiveView::History;
self.history.focus_handle(cx).focus(window);
if matches!(self.active_view, ActiveView::History) {
if let Some(previous_view) = self.previous_view.take() {
self.set_active_view(previous_view, window, cx);
}
} else {
self.thread_store
.update(cx, |thread_store, cx| thread_store.reload(cx))
.detach_and_log_err(cx);
self.set_active_view(ActiveView::History, window, cx);
}
cx.notify();
}
@@ -413,7 +495,7 @@ impl AssistantPanel {
cx,
)
});
this.active_view = ActiveView::PromptEditor;
this.set_active_view(ActiveView::PromptEditor, window, cx);
this.context_editor = Some(editor);
anyhow::Ok(())
@@ -435,7 +517,8 @@ impl AssistantPanel {
cx.spawn_in(window, async move |this, cx| {
let thread = open_thread_task.await?;
this.update_in(cx, |this, window, cx| {
this.active_view = ActiveView::Thread;
let thread_view = ActiveView::thread(thread.clone(), window, cx);
this.set_active_view(thread_view, window, cx);
let message_editor_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
this.workspace.clone(),
@@ -469,6 +552,17 @@ impl AssistantPanel {
})
}
pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
match self.active_view {
ActiveView::Configuration | ActiveView::History => {
self.active_view =
ActiveView::thread(self.thread.read(cx).thread().clone(), window, cx);
cx.notify();
}
_ => {}
}
}
pub fn open_agent_diff(
&mut self,
_: &OpenAgentDiff,
@@ -476,7 +570,11 @@ impl AssistantPanel {
cx: &mut Context<Self>,
) {
let thread = self.thread.read(cx).thread().clone();
AgentDiff::deploy(thread, self.workspace.clone(), window, cx).log_err();
self.workspace
.update(cx, |workspace, cx| {
AgentDiff::deploy_in_workspace(thread, workspace, window, cx)
})
.log_err();
}
pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -484,7 +582,7 @@ impl AssistantPanel {
let tools = self.thread_store.read(cx).tools();
let fs = self.fs.clone();
self.active_view = ActiveView::Configuration;
self.set_active_view(ActiveView::Configuration, window, cx);
self.configuration =
Some(cx.new(|cx| {
AssistantConfiguration::new(fs, context_server_manager, tools, window, cx)
@@ -570,10 +668,8 @@ impl AssistantPanel {
match event {
AssistantConfigurationEvent::NewThread(provider) => {
if LanguageModelRegistry::read_global(cx)
.active_provider()
.map_or(true, |active_provider| {
active_provider.id() != provider.id()
})
.default_model()
.map_or(true, |model| model.provider.id() != provider.id())
{
if let Some(model) = provider.default_model(cx) {
update_settings_file::<AssistantSettings>(
@@ -593,27 +689,56 @@ impl AssistantPanel {
self.thread.read(cx).thread().clone()
}
pub(crate) fn delete_thread(&mut self, thread_id: &ThreadId, cx: &mut Context<Self>) {
pub(crate) fn delete_thread(
&mut self,
thread_id: &ThreadId,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.thread_store
.update(cx, |this, cx| this.delete_thread(thread_id, cx))
.detach_and_log_err(cx);
}
pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
self.context_editor.clone()
}
pub(crate) fn delete_context(&mut self, path: PathBuf, cx: &mut Context<Self>) {
pub(crate) fn delete_context(
&mut self,
path: PathBuf,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.context_store
.update(cx, |this, cx| this.delete_local_context(path, cx))
.detach_and_log_err(cx);
}
fn set_active_view(
&mut self,
new_view: ActiveView,
window: &mut Window,
cx: &mut Context<Self>,
) {
let current_is_history = matches!(self.active_view, ActiveView::History);
let new_is_history = matches!(new_view, ActiveView::History);
if current_is_history && !new_is_history {
self.active_view = new_view;
} else if !current_is_history && new_is_history {
self.previous_view = Some(std::mem::replace(&mut self.active_view, new_view));
} else {
if !new_is_history {
self.previous_view = None;
}
self.active_view = new_view;
}
self.focus_handle(cx).focus(window);
}
}
impl Focusable for AssistantPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.active_view {
ActiveView::Thread => self.message_editor.focus_handle(cx),
ActiveView::Thread { .. } => self.message_editor.focus_handle(cx),
ActiveView::History => self.history.focus_handle(cx),
ActiveView::PromptEditor => {
if let Some(context_editor) = self.context_editor.as_ref() {
@@ -714,70 +839,190 @@ impl Panel for AssistantPanel {
}
impl AssistantPanel {
fn render_toolbar(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let thread = self.thread.read(cx);
let is_empty = thread.is_empty();
fn render_title_view(&self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…";
let thread_id = thread.thread().read(cx).id().clone();
let focus_handle = self.focus_handle(cx);
let content = match &self.active_view {
ActiveView::Thread {
change_title_editor,
..
} => {
let active_thread = self.thread.read(cx);
let is_empty = active_thread.is_empty();
let summary = active_thread.summary(cx);
let title = match self.active_view {
ActiveView::Thread => {
if is_empty {
thread.summary_or_default(cx)
Label::new(Thread::DEFAULT_SUMMARY.clone())
.truncate()
.into_any_element()
} else if summary.is_none() {
Label::new(LOADING_SUMMARY_PLACEHOLDER)
.truncate()
.into_any_element()
} else {
thread
.summary(cx)
.unwrap_or_else(|| SharedString::from("Loading Summary…"))
change_title_editor.clone().into_any_element()
}
}
ActiveView::PromptEditor => self
.context_editor
.as_ref()
.map(|context_editor| {
SharedString::from(context_editor.read(cx).title(cx).to_string())
})
.unwrap_or_else(|| SharedString::from("Loading Summary…")),
ActiveView::History => "History".into(),
ActiveView::Configuration => "Settings".into(),
ActiveView::PromptEditor => {
let title = self
.context_editor
.as_ref()
.map(|context_editor| {
SharedString::from(context_editor.read(cx).title(cx).to_string())
})
.unwrap_or_else(|| SharedString::from(LOADING_SUMMARY_PLACEHOLDER));
Label::new(title).truncate().into_any_element()
}
ActiveView::History => Label::new("History").truncate().into_any_element(),
ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
};
h_flex()
.key_context("TitleEditor")
.id("TitleEditor")
.flex_grow()
.w_full()
.max_w_full()
.overflow_x_scroll()
.child(content)
.into_any()
}
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let active_thread = self.thread.read(cx);
let thread = active_thread.thread().read(cx);
let token_usage = thread.total_token_usage(cx);
let thread_id = thread.id().clone();
let is_generating = thread.is_generating();
let is_empty = active_thread.is_empty();
let focus_handle = self.focus_handle(cx);
let is_history = matches!(self.active_view, ActiveView::History);
let show_token_count = match &self.active_view {
ActiveView::Thread { .. } => !is_empty,
ActiveView::PromptEditor => self.context_editor.is_some(),
_ => false,
};
let go_back_button = match &self.active_view {
ActiveView::History | ActiveView::Configuration => Some(
IconButton::new("go-back", IconName::ArrowLeft)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
this.go_back(&workspace::GoBack, window, cx);
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Go Back",
&workspace::GoBack,
&focus_handle,
window,
cx,
)
}
}),
),
_ => None,
};
h_flex()
.id("assistant-toolbar")
.h(Tab::container_height(cx))
.max_w_full()
.flex_none()
.justify_between()
.gap(DynamicSpacing::Base08.rems(cx))
.gap_2()
.bg(cx.theme().colors().tab_bar_background)
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
div()
.id("title")
.overflow_x_scroll()
.px(DynamicSpacing::Base08.rems(cx))
.child(Label::new(title).truncate()),
h_flex()
.w_full()
.pl_2()
.gap_2()
.children(go_back_button)
.child(self.render_title_view(window, cx)),
)
.child(
h_flex()
.h_full()
.pl_2()
.gap_2()
.bg(cx.theme().colors().tab_bar_background)
.children(if matches!(self.active_view, ActiveView::PromptEditor) {
self.context_editor
.as_ref()
.and_then(|editor| render_remaining_tokens(editor, cx))
} else {
None
.when(show_token_count, |parent| match self.active_view {
ActiveView::Thread { .. } => {
if token_usage.total == 0 {
return parent;
}
let token_color = match token_usage.ratio {
TokenUsageRatio::Normal => Color::Muted,
TokenUsageRatio::Warning => Color::Warning,
TokenUsageRatio::Exceeded => Color::Error,
};
parent.child(
h_flex()
.flex_shrink_0()
.gap_0p5()
.child(
Label::new(assistant_context_editor::humanize_token_count(
token_usage.total,
))
.size(LabelSize::Small)
.color(token_color)
.map(|label| {
if is_generating {
label
.with_animation(
"used-tokens-label",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(
0.6, 1.,
)),
|label, delta| label.alpha(delta),
)
.into_any()
} else {
label.into_any_element()
}
}),
)
.child(
Label::new("/").size(LabelSize::Small).color(Color::Muted),
)
.child(
Label::new(assistant_context_editor::humanize_token_count(
token_usage.max,
))
.size(LabelSize::Small)
.color(Color::Muted),
),
)
}
ActiveView::PromptEditor => {
let Some(editor) = self.context_editor.as_ref() else {
return parent;
};
let Some(element) = render_remaining_tokens(editor, cx) else {
return parent;
};
parent.child(element)
}
_ => parent,
})
.child(
h_flex()
.h_full()
.gap(DynamicSpacing::Base02.rems(cx))
.px(DynamicSpacing::Base08.rems(cx))
.border_l_1()
.border_color(cx.theme().colors().border)
.gap(DynamicSpacing::Base02.rems(cx))
.child(
IconButton::new("new", IconName::Plus)
.icon_size(IconSize::Small)
@@ -798,6 +1043,27 @@ impl AssistantPanel {
);
}),
)
.child(
IconButton::new("open-history", IconName::HistoryRerun)
.icon_size(IconSize::Small)
.toggle_state(is_history)
.selected_icon_color(Color::Accent)
.tooltip({
let focus_handle = self.focus_handle(cx);
move |window, cx| {
Tooltip::for_action_in(
"History",
&OpenHistory,
&focus_handle,
window,
cx,
)
}
})
.on_click(move |_event, window, cx| {
window.dispatch_action(OpenHistory.boxed_clone(), cx);
}),
)
.child(
PopoverMenu::new("assistant-menu")
.trigger_with_tooltip(
@@ -814,12 +1080,6 @@ impl AssistantPanel {
cx,
|menu, _window, _cx| {
menu.action(
"New Thread",
Box::new(NewThread {
from_thread_id: None,
}),
)
.action(
"New Prompt Editor",
NewPromptEditor.boxed_clone(),
)
@@ -832,7 +1092,6 @@ impl AssistantPanel {
)
})
.separator()
.action("History", OpenHistory.boxed_clone())
.action("Settings", OpenConfiguration.boxed_clone())
},
))
@@ -857,16 +1116,18 @@ impl AssistantPanel {
}
fn configuration_error(&self, cx: &App) -> Option<ConfigurationError> {
let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else {
return Some(ConfigurationError::NoProvider);
};
if !provider.is_authenticated(cx) {
if !model.provider.is_authenticated(cx) {
return Some(ConfigurationError::ProviderNotAuthenticated);
}
if provider.must_accept_terms(cx) {
return Some(ConfigurationError::ProviderPendingTermsAcceptance(provider));
if model.provider.must_accept_terms(cx) {
return Some(ConfigurationError::ProviderPendingTermsAcceptance(
model.provider,
));
}
None
@@ -1069,11 +1330,11 @@ impl AssistantPanel {
// TODO: Add keyboard navigation.
match entry {
HistoryEntry::Thread(thread) => {
PastThread::new(thread, cx.entity().downgrade(), false)
PastThread::new(thread, cx.entity().downgrade(), false, vec![])
.into_any_element()
}
HistoryEntry::Context(context) => {
PastContext::new(context, cx.entity().downgrade(), false)
PastContext::new(context, cx.entity().downgrade(), false, vec![])
.into_any_element()
}
}
@@ -1327,9 +1588,10 @@ impl Render for AssistantPanel {
.on_action(cx.listener(Self::open_active_thread_as_markdown))
.on_action(cx.listener(Self::deploy_prompt_library))
.on_action(cx.listener(Self::open_agent_diff))
.on_action(cx.listener(Self::go_back))
.child(self.render_toolbar(window, cx))
.map(|parent| match self.active_view {
ActiveView::Thread => parent
ActiveView::Thread { .. } => parent
.child(self.render_active_thread_or_empty_state(window, cx))
.child(h_flex().child(self.message_editor.clone()))
.children(self.render_last_error(cx)),

View File

@@ -28,7 +28,7 @@ use std::{
time::Instant,
};
use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
pub struct BufferCodegen {
alternatives: Vec<Entity<CodegenAlternative>>,
@@ -156,8 +156,9 @@ impl BufferCodegen {
}
let primary_model = LanguageModelRegistry::read_global(cx)
.active_model()
.context("no active model")?;
.default_model()
.context("no active model")?
.model;
for (model, alternative) in iter::once(primary_model)
.chain(alternative_models)
@@ -600,7 +601,7 @@ impl CodegenAlternative {
let error_message = result.as_ref().err().map(|error| error.to_string());
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
message_id,
kind: AssistantKind::Inline,

View File

@@ -146,11 +146,11 @@ pub struct ContextSymbolId {
pub range: Range<Anchor>,
}
pub fn attach_context_to_message<'a>(
message: &mut LanguageModelRequestMessage,
/// Formats a collection of contexts into a string representation
pub fn format_context_as_string<'a>(
contexts: impl Iterator<Item = &'a AssistantContext>,
cx: &App,
) {
) -> Option<String> {
let mut file_context = Vec::new();
let mut directory_context = Vec::new();
let mut symbol_context = Vec::new();
@@ -167,64 +167,78 @@ pub fn attach_context_to_message<'a>(
}
}
let mut context_chunks = Vec::new();
if file_context.is_empty()
&& directory_context.is_empty()
&& symbol_context.is_empty()
&& fetch_context.is_empty()
&& thread_context.is_empty()
{
return None;
}
let mut result = String::new();
result.push_str("\n<context>\n\
The following items were attached by the user. You don't need to use other tools to read them.\n\n");
if !file_context.is_empty() {
context_chunks.push("<files>\n");
result.push_str("<files>\n");
for context in file_context {
context_chunks.push(&context.context_buffer.text);
result.push_str(&context.context_buffer.text);
}
context_chunks.push("\n</files>\n");
result.push_str("</files>\n");
}
if !directory_context.is_empty() {
context_chunks.push("<directories>\n");
result.push_str("<directories>\n");
for context in directory_context {
for context_buffer in &context.context_buffers {
context_chunks.push(&context_buffer.text);
result.push_str(&context_buffer.text);
}
}
context_chunks.push("\n</directories>\n");
result.push_str("</directories>\n");
}
if !symbol_context.is_empty() {
context_chunks.push("<symbols>\n");
result.push_str("<symbols>\n");
for context in symbol_context {
context_chunks.push(&context.context_symbol.text);
result.push_str(&context.context_symbol.text);
result.push('\n');
}
context_chunks.push("\n</symbols>\n");
result.push_str("</symbols>\n");
}
if !fetch_context.is_empty() {
context_chunks.push("<fetched_urls>\n");
result.push_str("<fetched_urls>\n");
for context in &fetch_context {
context_chunks.push(&context.url);
context_chunks.push(&context.text);
result.push_str(&context.url);
result.push('\n');
result.push_str(&context.text);
result.push('\n');
}
context_chunks.push("\n</fetched_urls>\n");
result.push_str("</fetched_urls>\n");
}
// Need to own the SharedString for summary so that it can be referenced.
let mut thread_context_chunks = Vec::new();
if !thread_context.is_empty() {
context_chunks.push("<conversation_threads>\n");
result.push_str("<conversation_threads>\n");
for context in &thread_context {
thread_context_chunks.push(context.summary(cx));
thread_context_chunks.push(context.text.clone());
result.push_str(&context.summary(cx));
result.push('\n');
result.push_str(&context.text);
result.push('\n');
}
context_chunks.push("\n</conversation_threads>\n");
result.push_str("</conversation_threads>\n");
}
for chunk in &thread_context_chunks {
context_chunks.push(chunk);
}
result.push_str("</context>\n");
Some(result)
}
if !context_chunks.is_empty() {
message.content.push(
"\n<context>\n\
The following items were attached by the user. You don't need to use other tools to read them.\n\n".into(),
);
message.content.push(context_chunks.join("\n").into());
message.content.push("\n</context>\n".into());
pub fn attach_context_to_message<'a>(
message: &mut LanguageModelRequestMessage,
contexts: impl Iterator<Item = &'a AssistantContext>,
cx: &App,
) {
if let Some(context_string) = format_context_as_string(contexts, cx) {
message.content.push(context_string.into());
}
}

View File

@@ -13,7 +13,8 @@ use editor::display_map::{Crease, FoldId};
use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
use file_context_picker::render_file_context_entry;
use gpui::{
App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task,
WeakEntity,
};
use multi_buffer::MultiBufferRow;
use project::{Entry, ProjectPath};
@@ -76,7 +77,7 @@ impl ContextPickerMode {
Self::File => "Files & Directories",
Self::Symbol => "Symbols",
Self::Fetch => "Fetch",
Self::Thread => "Thread",
Self::Thread => "Threads",
}
}
@@ -105,6 +106,7 @@ pub(super) struct ContextPicker {
context_store: WeakEntity<ContextStore>,
thread_store: Option<WeakEntity<ThreadStore>>,
confirm_behavior: ConfirmBehavior,
_subscriptions: Vec<Subscription>,
}
impl ContextPicker {
@@ -116,6 +118,22 @@ impl ContextPicker {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let subscriptions = context_store
.upgrade()
.map(|context_store| {
cx.observe(&context_store, |this, _, cx| this.notify_current_picker(cx))
})
.into_iter()
.chain(
thread_store
.as_ref()
.and_then(|thread_store| thread_store.upgrade())
.map(|thread_store| {
cx.observe(&thread_store, |this, _, cx| this.notify_current_picker(cx))
}),
)
.collect::<Vec<Subscription>>();
ContextPicker {
mode: ContextPickerState::Default(ContextMenu::build(
window,
@@ -126,6 +144,7 @@ impl ContextPicker {
context_store,
thread_store,
confirm_behavior,
_subscriptions: subscriptions,
}
}
@@ -360,73 +379,25 @@ impl ContextPicker {
}
fn recent_entries(&self, cx: &mut App) -> Vec<RecentEntry> {
let Some(workspace) = self.workspace.upgrade().map(|w| w.read(cx)) else {
let Some(workspace) = self.workspace.upgrade() else {
return vec![];
};
let Some(context_store) = self.context_store.upgrade().map(|cs| cs.read(cx)) else {
let Some(context_store) = self.context_store.upgrade() else {
return vec![];
};
let mut recent = Vec::with_capacity(6);
recent_context_picker_entries(context_store, self.thread_store.clone(), workspace, cx)
}
let mut current_files = context_store.file_paths(cx);
if let Some(active_path) = active_singleton_buffer_path(&workspace, cx) {
current_files.insert(active_path);
fn notify_current_picker(&mut self, cx: &mut Context<Self>) {
match &self.mode {
ContextPickerState::Default(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::File(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::Symbol(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::Fetch(entity) => entity.update(cx, |_, cx| cx.notify()),
ContextPickerState::Thread(entity) => entity.update(cx, |_, cx| cx.notify()),
}
let project = workspace.project().read(cx);
recent.extend(
workspace
.recent_navigation_history_iter(cx)
.filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
.take(4)
.filter_map(|(project_path, _)| {
project
.worktree_for_id(project_path.worktree_id, cx)
.map(|worktree| RecentEntry::File {
project_path,
path_prefix: worktree.read(cx).root_name().into(),
})
}),
);
let mut current_threads = context_store.thread_ids();
if let Some(active_thread) = workspace
.panel::<AssistantPanel>(cx)
.map(|panel| panel.read(cx).active_thread(cx))
{
current_threads.insert(active_thread.read(cx).id().clone());
}
let Some(thread_store) = self
.thread_store
.as_ref()
.and_then(|thread_store| thread_store.upgrade())
else {
return recent;
};
thread_store.update(cx, |thread_store, _cx| {
recent.extend(
thread_store
.threads()
.into_iter()
.filter(|thread| !current_threads.contains(&thread.id))
.take(2)
.map(|thread| {
RecentEntry::Thread(ThreadContextEntry {
id: thread.id,
summary: thread.summary,
})
}),
)
});
recent
}
}
@@ -480,16 +451,6 @@ fn supported_context_picker_modes(
modes
}
fn active_singleton_buffer_path(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
let active_item = workspace.active_item(cx)?;
let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
let buffer = editor.buffer().read(cx).as_singleton()?;
let path = buffer.read(cx).file()?.path().to_path_buf();
Some(path)
}
fn recent_context_picker_entries(
context_store: Entity<ContextStore>,
thread_store: Option<WeakEntity<ThreadStore>>,
@@ -498,14 +459,8 @@ fn recent_context_picker_entries(
) -> Vec<RecentEntry> {
let mut recent = Vec::with_capacity(6);
let mut current_files = context_store.read(cx).file_paths(cx);
let current_files = context_store.read(cx).file_paths(cx);
let workspace = workspace.read(cx);
if let Some(active_path) = active_singleton_buffer_path(workspace, cx) {
current_files.insert(active_path);
}
let project = workspace.project().read(cx);
recent.extend(
@@ -683,24 +638,45 @@ fn fold_toggle(
pub enum MentionLink {
File(ProjectPath, Entry),
Symbol(ProjectPath, String),
Fetch(String),
Thread(ThreadId),
}
impl MentionLink {
const FILE: &str = "@file";
const SYMBOL: &str = "@symbol";
const THREAD: &str = "@thread";
const FETCH: &str = "@fetch";
const SEPARATOR: &str = ":";
pub fn is_valid(url: &str) -> bool {
url.starts_with(Self::FILE)
|| url.starts_with(Self::SYMBOL)
|| url.starts_with(Self::FETCH)
|| url.starts_with(Self::THREAD)
}
pub fn for_file(file_name: &str, full_path: &str) -> String {
format!("[@{}](file:{})", file_name, full_path)
format!("[@{}]({}:{})", file_name, Self::FILE, full_path)
}
pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name)
format!(
"[@{}]({}:{}:{})",
symbol_name,
Self::SYMBOL,
full_path,
symbol_name
)
}
pub fn for_fetch(url: &str) -> String {
format!("[@{}]({})", url, url)
format!("[@{}]({}:{})", url, Self::FETCH, url)
}
pub fn for_thread(thread: &ThreadContextEntry) -> String {
format!("[@{}](thread:{})", thread.summary, thread.id)
format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id)
}
pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
@@ -723,17 +699,10 @@ impl MentionLink {
})
}
let (prefix, link, target) = {
let mut parts = link.splitn(3, ':');
let prefix = parts.next();
let link = parts.next();
let target = parts.next();
(prefix, link, target)
};
match (prefix, link, target) {
(Some("file"), Some(path), _) => {
let project_path = extract_project_path_from_link(path, workspace, cx)?;
let (prefix, argument) = link.split_once(Self::SEPARATOR)?;
match prefix {
Self::FILE => {
let project_path = extract_project_path_from_link(argument, workspace, cx)?;
let entry = workspace
.read(cx)
.project()
@@ -741,14 +710,16 @@ impl MentionLink {
.entry_for_path(&project_path, cx)?;
Some(MentionLink::File(project_path, entry))
}
(Some("symbol"), Some(path), Some(symbol_name)) => {
Self::SYMBOL => {
let (path, symbol) = argument.split_once(Self::SEPARATOR)?;
let project_path = extract_project_path_from_link(path, workspace, cx)?;
Some(MentionLink::Symbol(project_path, symbol_name.to_string()))
Some(MentionLink::Symbol(project_path, symbol.to_string()))
}
(Some("thread"), Some(thread_id), _) => {
let thread_id = ThreadId::from(thread_id);
Self::THREAD => {
let thread_id = ThreadId::from(argument);
Some(MentionLink::Thread(thread_id))
}
Self::FETCH => Some(MentionLink::Fetch(argument.to_string())),
_ => None,
}
}

View File

@@ -112,6 +112,7 @@ impl ContextPickerCompletionProvider {
icon_path: Some(mode.icon().path().into()),
documentation: None,
source: project::CompletionSource::Custom,
insert_text_mode: None,
// This ensures that when a user accepts this completion, the
// completion menu will still be shown after "@category " is
// inserted
@@ -163,6 +164,7 @@ impl ContextPickerCompletionProvider {
new_text,
label: CodeLabel::plain(thread_entry.summary.to_string(), None),
documentation: None,
insert_text_mode: None,
source: project::CompletionSource::Custom,
icon_path: Some(icon_for_completion.path().into()),
confirm: Some(confirm_completion_callback(
@@ -209,6 +211,7 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(IconName::Globe.path().into()),
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
IconName::Globe.path().into(),
url_to_fetch.clone(),
@@ -232,8 +235,8 @@ impl ContextPickerCompletionProvider {
url_to_fetch.to_string(),
))
.await?;
context_store.update(cx, |context_store, _| {
context_store.add_fetched_url(url_to_fetch.to_string(), content)
context_store.update(cx, |context_store, cx| {
context_store.add_fetched_url(url_to_fetch.to_string(), content, cx)
})
})
.detach_and_log_err(cx);
@@ -290,6 +293,7 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(completion_icon_path),
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
crease_icon_path,
file_name,
@@ -352,6 +356,7 @@ impl ContextPickerCompletionProvider {
documentation: None,
source: project::CompletionSource::Custom,
icon_path: Some(IconName::Code.path().into()),
insert_text_mode: None,
confirm: Some(confirm_completion_callback(
IconName::Code.path().into(),
symbol.name.clone().into(),
@@ -890,10 +895,10 @@ mod tests {
assert_eq!(
current_completion_labels(editor),
&[
"editor dir/",
"seven.txt dir/b/",
"six.txt dir/b/",
"five.txt dir/b/",
"four.txt dir/a/",
"Files & Directories",
"Symbols",
"Fetch"
@@ -932,22 +937,22 @@ mod tests {
});
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt)",);
assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt)",);
assert!(!editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 36)]
vec![Point::new(0, 6)..Point::new(0, 37)]
);
});
cx.simulate_input(" ");
editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt) ",);
assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ",);
assert!(!editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 36)]
vec![Point::new(0, 6)..Point::new(0, 37)]
);
});
@@ -956,12 +961,12 @@ mod tests {
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum ",
"Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ",
);
assert!(!editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 36)]
vec![Point::new(0, 6)..Point::new(0, 37)]
);
});
@@ -970,12 +975,12 @@ mod tests {
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum @file ",
"Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ",
);
assert!(editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 36)]
vec![Point::new(0, 6)..Point::new(0, 37)]
);
});
@@ -988,14 +993,14 @@ mod tests {
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)"
"Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)"
);
assert!(!editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![
Point::new(0, 6)..Point::new(0, 36),
Point::new(0, 43)..Point::new(0, 77)
Point::new(0, 6)..Point::new(0, 37),
Point::new(0, 44)..Point::new(0, 71)
]
);
});
@@ -1005,14 +1010,14 @@ mod tests {
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n@"
"Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n@"
);
assert!(editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![
Point::new(0, 6)..Point::new(0, 36),
Point::new(0, 43)..Point::new(0, 77)
Point::new(0, 6)..Point::new(0, 37),
Point::new(0, 44)..Point::new(0, 71)
]
);
});
@@ -1026,15 +1031,15 @@ mod tests {
editor.update(&mut cx, |editor, cx| {
assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n[@six.txt](file:dir/b/six.txt)"
"Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n[@seven.txt](@file:dir/b/seven.txt)"
);
assert!(!editor.has_visible_completions_menu());
assert_eq!(
crease_ranges(editor, cx),
vec![
Point::new(0, 6)..Point::new(0, 36),
Point::new(0, 43)..Point::new(0, 77),
Point::new(1, 0)..Point::new(1, 30)
Point::new(0, 6)..Point::new(0, 37),
Point::new(0, 44)..Point::new(0, 71),
Point::new(1, 0)..Point::new(1, 35)
]
);
});

View File

@@ -213,8 +213,8 @@ impl PickerDelegate for FetchContextPickerDelegate {
this.update_in(cx, |this, window, cx| {
this.delegate
.context_store
.update(cx, |context_store, _cx| {
context_store.add_fetched_url(url, text);
.update(cx, |context_store, cx| {
context_store.add_fetched_url(url, text, cx)
})?;
match confirm_behavior {

View File

@@ -6,7 +6,7 @@ use anyhow::{Context as _, Result, anyhow};
use collections::{BTreeMap, HashMap, HashSet};
use futures::future::join_all;
use futures::{self, Future, FutureExt, future};
use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task, WeakEntity};
use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity};
use language::{Buffer, File};
use project::{ProjectItem, ProjectPath, Worktree};
use rope::Rope;
@@ -95,14 +95,14 @@ impl ContextStore {
project.open_buffer(project_path.clone(), cx)
})?;
let buffer_entity = open_buffer_task.await?;
let buffer_id = this.update(cx, |_, cx| buffer_entity.read(cx).remote_id())?;
let buffer = open_buffer_task.await?;
let buffer_id = this.update(cx, |_, cx| buffer.read(cx).remote_id())?;
let already_included = this.update(cx, |this, _cx| {
let already_included = this.update(cx, |this, cx| {
match this.will_include_buffer(buffer_id, &project_path.path) {
Some(FileInclusion::Direct(context_id)) => {
if remove_if_exists {
this.remove_context(context_id);
this.remove_context(context_id, cx);
}
true
}
@@ -115,21 +115,13 @@ impl ContextStore {
return anyhow::Ok(());
}
let (buffer_info, text_task) = this.update(cx, |_, cx| {
let buffer = buffer_entity.read(cx);
collect_buffer_info_and_text(
project_path.path.clone(),
buffer_entity,
buffer,
None,
cx.to_async(),
)
})??;
let (buffer_info, text_task) =
this.update(cx, |_, cx| collect_buffer_info_and_text(buffer, None, cx))??;
let text = text_task.await;
this.update(cx, |this, _cx| {
this.insert_file(make_context_buffer(buffer_info, text));
this.update(cx, |this, cx| {
this.insert_file(make_context_buffer(buffer_info, text), cx);
})?;
anyhow::Ok(())
@@ -138,41 +130,29 @@ impl ContextStore {
pub fn add_file_from_buffer(
&mut self,
buffer_entity: Entity<Buffer>,
buffer: Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
cx.spawn(async move |this, cx| {
let (buffer_info, text_task) = this.update(cx, |_, cx| {
let buffer = buffer_entity.read(cx);
let Some(file) = buffer.file() else {
return Err(anyhow!("Buffer has no path."));
};
collect_buffer_info_and_text(
file.path().clone(),
buffer_entity,
buffer,
None,
cx.to_async(),
)
})??;
let (buffer_info, text_task) =
this.update(cx, |_, cx| collect_buffer_info_and_text(buffer, None, cx))??;
let text = text_task.await;
this.update(cx, |this, _cx| {
this.insert_file(make_context_buffer(buffer_info, text))
this.update(cx, |this, cx| {
this.insert_file(make_context_buffer(buffer_info, text), cx)
})?;
anyhow::Ok(())
})
}
fn insert_file(&mut self, context_buffer: ContextBuffer) {
fn insert_file(&mut self, context_buffer: ContextBuffer, cx: &mut Context<Self>) {
let id = self.next_context_id.post_inc();
self.files.insert(context_buffer.id, id);
self.context.push(AssistantContext::File(FileContext {
id,
context_buffer: context_buffer,
}));
self.context
.push(AssistantContext::File(FileContext { id, context_buffer }));
cx.notify();
}
pub fn add_directory(
@@ -192,7 +172,7 @@ impl ContextStore {
let already_included = match self.includes_directory(&project_path.path) {
Some(FileInclusion::Direct(context_id)) => {
if remove_if_exists {
self.remove_context(context_id);
self.remove_context(context_id, cx);
}
true
}
@@ -233,22 +213,13 @@ impl ContextStore {
let mut buffer_infos = Vec::new();
let mut text_tasks = Vec::new();
this.update(cx, |_, cx| {
for (path, buffer_entity) in files.into_iter().zip(buffers) {
// Skip all binary files and other non-UTF8 files
if let Ok(buffer_entity) = buffer_entity {
let buffer = buffer_entity.read(cx);
if let Some((buffer_info, text_task)) = collect_buffer_info_and_text(
path,
buffer_entity,
buffer,
None,
cx.to_async(),
)
.log_err()
{
buffer_infos.push(buffer_info);
text_tasks.push(text_task);
}
// Skip all binary files and other non-UTF8 files
for buffer in buffers.into_iter().flatten() {
if let Some((buffer_info, text_task)) =
collect_buffer_info_and_text(buffer, None, cx).log_err()
{
buffer_infos.push(buffer_info);
text_tasks.push(text_task);
}
}
anyhow::Ok(())
@@ -268,15 +239,20 @@ impl ContextStore {
));
}
this.update(cx, |this, _| {
this.insert_directory(project_path, context_buffers);
this.update(cx, |this, cx| {
this.insert_directory(project_path, context_buffers, cx);
})?;
anyhow::Ok(())
})
}
fn insert_directory(&mut self, project_path: ProjectPath, context_buffers: Vec<ContextBuffer>) {
fn insert_directory(
&mut self,
project_path: ProjectPath,
context_buffers: Vec<ContextBuffer>,
cx: &mut Context<Self>,
) {
let id = self.next_context_id.post_inc();
self.directories.insert(project_path.path.to_path_buf(), id);
@@ -286,6 +262,7 @@ impl ContextStore {
project_path,
context_buffers,
}));
cx.notify();
}
pub fn add_symbol(
@@ -298,12 +275,8 @@ impl ContextStore {
cx: &mut Context<Self>,
) -> Task<Result<bool>> {
let buffer_ref = buffer.read(cx);
let Some(file) = buffer_ref.file() else {
return Task::ready(Err(anyhow!("Buffer has no path.")));
};
let Some(project_path) = buffer_ref.project_path(cx) else {
return Task::ready(Err(anyhow!("Buffer has no project path.")));
return Task::ready(Err(anyhow!("buffer has no path")));
};
if let Some(symbols_for_path) = self.symbols_by_path.get(&project_path) {
@@ -320,41 +293,39 @@ impl ContextStore {
if let Some(id) = matching_symbol_id {
if remove_if_exists {
self.remove_context(id);
self.remove_context(id, cx);
}
return Task::ready(Ok(false));
}
}
let (buffer_info, collect_content_task) = match collect_buffer_info_and_text(
file.path().clone(),
buffer,
buffer_ref,
Some(symbol_enclosing_range.clone()),
cx.to_async(),
) {
Ok((buffer_info, collect_context_task)) => (buffer_info, collect_context_task),
Err(err) => return Task::ready(Err(err)),
};
let (buffer_info, collect_content_task) =
match collect_buffer_info_and_text(buffer, Some(symbol_enclosing_range.clone()), cx) {
Ok((buffer_info, collect_context_task)) => (buffer_info, collect_context_task),
Err(err) => return Task::ready(Err(err)),
};
cx.spawn(async move |this, cx| {
let content = collect_content_task.await;
this.update(cx, |this, _cx| {
this.insert_symbol(make_context_symbol(
buffer_info,
project_path,
symbol_name,
symbol_range,
symbol_enclosing_range,
content,
))
this.update(cx, |this, cx| {
this.insert_symbol(
make_context_symbol(
buffer_info,
project_path,
symbol_name,
symbol_range,
symbol_enclosing_range,
content,
),
cx,
)
})?;
anyhow::Ok(true)
})
}
fn insert_symbol(&mut self, context_symbol: ContextSymbol) {
fn insert_symbol(&mut self, context_symbol: ContextSymbol, cx: &mut Context<Self>) {
let id = self.next_context_id.post_inc();
self.symbols.insert(context_symbol.id.clone(), id);
self.symbols_by_path
@@ -367,6 +338,7 @@ impl ContextStore {
id,
context_symbol,
}));
cx.notify();
}
pub fn add_thread(
@@ -377,7 +349,7 @@ impl ContextStore {
) {
if let Some(context_id) = self.includes_thread(&thread.read(cx).id()) {
if remove_if_exists {
self.remove_context(context_id);
self.remove_context(context_id, cx);
}
} else {
self.insert_thread(thread, cx);
@@ -392,14 +364,14 @@ impl ContextStore {
})
}
fn insert_thread(&mut self, thread: Entity<Thread>, cx: &mut App) {
fn insert_thread(&mut self, thread: Entity<Thread>, cx: &mut Context<Self>) {
if let Some(summary_task) =
thread.update(cx, |thread, cx| thread.generate_detailed_summary(cx))
{
let thread = thread.clone();
let thread_store = self.thread_store.clone();
self.thread_summary_tasks.push(cx.spawn(async move |cx| {
self.thread_summary_tasks.push(cx.spawn(async move |_, cx| {
summary_task.await;
if let Some(thread_store) = thread_store {
@@ -421,15 +393,26 @@ impl ContextStore {
self.threads.insert(thread.read(cx).id().clone(), id);
self.context
.push(AssistantContext::Thread(ThreadContext { id, thread, text }));
cx.notify();
}
pub fn add_fetched_url(&mut self, url: String, text: impl Into<SharedString>) {
pub fn add_fetched_url(
&mut self,
url: String,
text: impl Into<SharedString>,
cx: &mut Context<ContextStore>,
) {
if self.includes_url(&url).is_none() {
self.insert_fetched_url(url, text);
self.insert_fetched_url(url, text, cx);
}
}
fn insert_fetched_url(&mut self, url: String, text: impl Into<SharedString>) {
fn insert_fetched_url(
&mut self,
url: String,
text: impl Into<SharedString>,
cx: &mut Context<ContextStore>,
) {
let id = self.next_context_id.post_inc();
self.fetched_urls.insert(url.clone(), id);
@@ -439,6 +422,7 @@ impl ContextStore {
url: url.into(),
text: text.into(),
}));
cx.notify();
}
pub fn accept_suggested_context(
@@ -465,7 +449,7 @@ impl ContextStore {
Task::ready(Ok(()))
}
pub fn remove_context(&mut self, id: ContextId) {
pub fn remove_context(&mut self, id: ContextId, cx: &mut Context<Self>) {
let Some(ix) = self.context.iter().position(|context| context.id() == id) else {
return;
};
@@ -497,6 +481,8 @@ impl ContextStore {
self.threads.retain(|_, context_id| *context_id != id);
}
}
cx.notify();
}
/// Returns whether the buffer is already included directly in the context, or if it will be
@@ -616,16 +602,16 @@ pub enum FileInclusion {
// ContextBuffer without text.
struct BufferInfo {
buffer_entity: Entity<Buffer>,
file: Arc<dyn File>,
id: BufferId,
buffer: Entity<Buffer>,
file: Arc<dyn File>,
version: clock::Global,
}
fn make_context_buffer(info: BufferInfo, text: SharedString) -> ContextBuffer {
ContextBuffer {
id: info.id,
buffer: info.buffer_entity,
buffer: info.buffer,
file: info.file,
version: info.version,
text,
@@ -644,34 +630,37 @@ fn make_context_symbol(
id: ContextSymbolId { name, range, path },
buffer_version: info.version,
enclosing_range,
buffer: info.buffer_entity,
buffer: info.buffer,
text,
}
}
fn collect_buffer_info_and_text(
path: Arc<Path>,
buffer_entity: Entity<Buffer>,
buffer: &Buffer,
buffer: Entity<Buffer>,
range: Option<Range<Anchor>>,
cx: AsyncApp,
cx: &App,
) -> Result<(BufferInfo, Task<SharedString>)> {
let buffer_info = BufferInfo {
id: buffer.remote_id(),
buffer_entity,
file: buffer
.file()
.context("buffer context must have a file")?
.clone(),
version: buffer.version(),
};
let buffer_ref = buffer.read(cx);
let file = buffer_ref.file().context("file context must have a path")?;
// Important to collect version at the same time as content so that staleness logic is correct.
let version = buffer_ref.version();
let content = if let Some(range) = range {
buffer.text_for_range(range).collect::<Rope>()
buffer_ref.text_for_range(range).collect::<Rope>()
} else {
buffer.as_rope().clone()
buffer_ref.as_rope().clone()
};
let text_task = cx.background_spawn(async move { to_fenced_codeblock(&path, content) });
let buffer_info = BufferInfo {
buffer,
id: buffer_ref.remote_id(),
file: file.clone(),
version,
};
let full_path = file.full_path(cx);
let text_task = cx.background_spawn(async move { to_fenced_codeblock(&full_path, content) });
Ok((buffer_info, text_task))
}
@@ -920,16 +909,9 @@ fn refresh_context_buffer(
cx: &App,
) -> Option<impl Future<Output = ContextBuffer> + use<>> {
let buffer = context_buffer.buffer.read(cx);
let path = buffer_path_log_err(buffer, cx)?;
if buffer.version.changed_since(&context_buffer.version) {
let (buffer_info, text_task) = collect_buffer_info_and_text(
path,
context_buffer.buffer.clone(),
buffer,
None,
cx.to_async(),
)
.log_err()?;
let (buffer_info, text_task) =
collect_buffer_info_and_text(context_buffer.buffer.clone(), None, cx).log_err()?;
Some(text_task.map(move |text| make_context_buffer(buffer_info, text)))
} else {
None
@@ -941,15 +923,12 @@ fn refresh_context_symbol(
cx: &App,
) -> Option<impl Future<Output = ContextSymbol> + use<>> {
let buffer = context_symbol.buffer.read(cx);
let path = buffer_path_log_err(buffer, cx)?;
let project_path = buffer.project_path(cx)?;
if buffer.version.changed_since(&context_symbol.buffer_version) {
let (buffer_info, text_task) = collect_buffer_info_and_text(
path,
context_symbol.buffer.clone(),
buffer,
Some(context_symbol.enclosing_range.clone()),
cx.to_async(),
cx,
)
.log_err()?;
let name = context_symbol.id.name.clone();

View File

@@ -59,6 +59,7 @@ impl ContextStrip {
let focus_handle = cx.focus_handle();
let subscriptions = vec![
cx.observe(&context_store, |_, _, cx| cx.notify()),
cx.subscribe_in(&context_picker, window, Self::handle_context_picker_event),
cx.on_focus(&focus_handle, window, Self::handle_focus),
cx.on_blur(&focus_handle, window, Self::handle_blur),
@@ -290,9 +291,9 @@ impl ContextStrip {
if let Some(index) = self.focused_index {
let mut is_empty = false;
self.context_store.update(cx, |this, _cx| {
self.context_store.update(cx, |this, cx| {
if let Some(item) = this.context().get(index) {
this.remove_context(item.id());
this.remove_context(item.id(), cx);
}
is_empty = this.context().is_empty();
@@ -475,8 +476,8 @@ impl Render for ContextStrip {
Some({
let context_store = self.context_store.clone();
Rc::new(cx.listener(move |_this, _event, _window, cx| {
context_store.update(cx, |this, _cx| {
this.remove_context(id);
context_store.update(cx, |this, cx| {
this.remove_context(id, cx);
});
cx.notify();
}))

View File

@@ -4,6 +4,7 @@ use gpui::{Entity, prelude::*};
use crate::thread_store::{SerializedThreadMetadata, ThreadStore};
#[derive(Debug)]
pub enum HistoryEntry {
Thread(SerializedThreadMetadata),
Context(SavedContextMetadata),
@@ -21,25 +22,27 @@ impl HistoryEntry {
pub struct HistoryStore {
thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>,
_subscriptions: Vec<gpui::Subscription>,
}
impl HistoryStore {
pub fn new(
thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>,
_cx: &mut Context<Self>,
cx: &mut Context<Self>,
) -> Self {
let subscriptions = vec![
cx.observe(&thread_store, |_, _, cx| cx.notify()),
cx.observe(&context_store, |_, _, cx| cx.notify()),
];
Self {
thread_store,
context_store,
_subscriptions: subscriptions,
}
}
/// Returns the number of history entries.
pub fn entry_count(&self, cx: &mut Context<Self>) -> usize {
self.entries(cx).len()
}
pub fn entries(&self, cx: &mut Context<Self>) -> Vec<HistoryEntry> {
let mut history_entries = Vec::new();

View File

@@ -31,14 +31,14 @@ use project::LspAction;
use project::{CodeAction, ProjectTransaction};
use prompt_store::PromptBuilder;
use settings::{Settings, SettingsStore};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use text::{OffsetRangeExt, ToPoint as _};
use ui::prelude::*;
use util::RangeExt;
use util::ResultExt;
use workspace::{ItemHandle, Toast, Workspace, notifications::NotificationId};
use workspace::{ShowConfiguration, dock::Panel};
use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId};
use zed_actions::agent::OpenConfiguration;
use crate::AssistantPanel;
use crate::buffer_codegen::{BufferCodegen, CodegenAlternative, CodegenEvent};
@@ -239,8 +239,8 @@ impl InlineAssistant {
let is_authenticated = || {
LanguageModelRegistry::read_global(cx)
.active_provider()
.map_or(false, |provider| provider.is_authenticated(cx))
.inline_assistant_model()
.map_or(false, |model| model.provider.is_authenticated(cx))
};
let thread_store = workspace
@@ -279,8 +279,8 @@ impl InlineAssistant {
cx.spawn_in(window, async move |_workspace, cx| {
let Some(task) = cx.update(|_, cx| {
LanguageModelRegistry::read_global(cx)
.active_provider()
.map_or(None, |provider| Some(provider.authenticate(cx)))
.inline_assistant_model()
.map_or(None, |model| Some(model.provider.authenticate(cx)))
})?
else {
let answer = cx
@@ -295,7 +295,7 @@ impl InlineAssistant {
if let Some(answer) = answer {
if answer == 0 {
cx.update(|window, cx| {
window.dispatch_action(Box::new(ShowConfiguration), cx)
window.dispatch_action(Box::new(OpenConfiguration), cx)
})
.ok();
}
@@ -401,14 +401,14 @@ impl InlineAssistant {
codegen_ranges.push(anchor_range);
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
self.telemetry.report_assistant_event(AssistantEvent {
if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() {
self.telemetry.report_assistant_event(AssistantEventData {
conversation_id: None,
kind: AssistantKind::Inline,
phase: AssistantPhase::Invoked,
message_id: None,
model: model.telemetry_id(),
model_provider: model.provider_id().to_string(),
model: model.model.telemetry_id(),
model_provider: model.provider.id().to_string(),
response_latency: None,
error_message: None,
language_name: buffer.language().map(|language| language.name().to_proto()),
@@ -621,14 +621,14 @@ impl InlineAssistant {
BlockProperties {
style: BlockStyle::Sticky,
placement: BlockPlacement::Above(range.start),
height: prompt_editor_height,
height: Some(prompt_editor_height),
render: build_assist_editor_renderer(prompt_editor),
priority: 0,
},
BlockProperties {
style: BlockStyle::Sticky,
placement: BlockPlacement::Below(range.end),
height: 0,
height: None,
render: Arc::new(|cx| {
v_flex()
.h_full()
@@ -976,7 +976,7 @@ impl InlineAssistant {
let active_alternative = assist.codegen.read(cx).active_alternative().clone();
let message_id = active_alternative.read(cx).message_id.clone();
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() {
let language_name = assist.editor.upgrade().and_then(|editor| {
let multibuffer = editor.read(cx).buffer().read(cx);
let snapshot = multibuffer.snapshot(cx);
@@ -987,7 +987,7 @@ impl InlineAssistant {
.map(|language| language.name())
});
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
kind: AssistantKind::Inline,
message_id,
@@ -996,15 +996,15 @@ impl InlineAssistant {
} else {
AssistantPhase::Accepted
},
model: model.telemetry_id(),
model_provider: model.provider_id().to_string(),
model: model.model.telemetry_id(),
model_provider: model.model.provider_id().to_string(),
response_latency: None,
error_message: None,
language_name: language_name.map(|name| name.to_proto()),
},
Some(self.telemetry.clone()),
cx.http_client(),
model.api_key(cx),
model.model.api_key(cx),
cx.background_executor(),
);
}
@@ -1392,7 +1392,7 @@ impl InlineAssistant {
deleted_lines_editor.update(cx, |editor, cx| editor.max_point(cx).row().0 + 1);
new_blocks.push(BlockProperties {
placement: BlockPlacement::Above(new_row),
height,
height: Some(height),
style: BlockStyle::Flex,
render: Arc::new(move |cx| {
div()

View File

@@ -1,4 +1,4 @@
use crate::assistant_model_selector::AssistantModelSelector;
use crate::assistant_model_selector::{AssistantModelSelector, ModelType};
use crate::buffer_codegen::BufferCodegen;
use crate::context_picker::ContextPicker;
use crate::context_store::ContextStore;
@@ -582,7 +582,7 @@ impl<T: 'static> PromptEditor<T> {
let disabled = matches!(codegen.status(cx), CodegenStatus::Idle);
let model_registry = LanguageModelRegistry::read_global(cx);
let default_model = model_registry.active_model();
let default_model = model_registry.default_model().map(|default| default.model);
let alternative_models = model_registry.inline_alternative_models();
let get_model_name = |index: usize| -> String {
@@ -890,6 +890,7 @@ impl PromptEditor<BufferCodegen> {
fs,
model_selector_menu_handle,
prompt_editor.focus_handle(cx),
ModelType::InlineAssistant,
window,
cx,
)
@@ -1042,6 +1043,7 @@ impl PromptEditor<TerminalCodegen> {
fs,
model_selector_menu_handle.clone(),
prompt_editor.focus_handle(cx),
ModelType::InlineAssistant,
window,
cx,
)

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use crate::assistant_model_selector::ModelType;
use collections::HashSet;
use editor::actions::MoveUp;
use editor::{ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorStyle};
@@ -7,20 +8,18 @@ use file_icons::FileIcons;
use fs::Fs;
use gpui::{
Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
WeakEntity, linear_color_stop, linear_gradient, point,
WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
};
use language_model::LanguageModelRegistry;
use language::Buffer;
use language_model::{ConfiguredModel, LanguageModelRegistry};
use language_model_selector::ToggleModelSelector;
use multi_buffer;
use project::Project;
use settings::Settings;
use std::time::Duration;
use theme::ThemeSettings;
use ui::{
ButtonLike, Disclosure, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Tooltip,
prelude::*,
};
use ui::{Disclosure, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
use util::ResultExt as _;
use vim_mode_setting::VimModeSetting;
use workspace::Workspace;
use crate::assistant_model_selector::AssistantModelSelector;
@@ -28,7 +27,7 @@ use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerComplet
use crate::context_store::{ContextStore, refresh_context_store_text};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
use crate::profile_selector::ProfileSelector;
use crate::thread::{RequestKind, Thread};
use crate::thread::{RequestKind, Thread, TokenUsageRatio};
use crate::thread_store::ThreadStore;
use crate::{
AgentDiff, Chat, ChatMode, NewThread, OpenAgentDiff, RemoveAllContext, ThreadEvent,
@@ -137,6 +136,7 @@ impl MessageEditor {
fs.clone(),
model_selector_menu_handle,
editor.focus_handle(cx),
ModelType::Default,
window,
cx,
)
@@ -189,7 +189,7 @@ impl MessageEditor {
fn is_model_selected(&self, cx: &App) -> bool {
LanguageModelRegistry::read_global(cx)
.active_model()
.default_model()
.is_some()
}
@@ -199,20 +199,16 @@ impl MessageEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let provider = LanguageModelRegistry::read_global(cx).active_provider();
if provider
.as_ref()
.map_or(false, |provider| provider.must_accept_terms(cx))
{
let model_registry = LanguageModelRegistry::read_global(cx);
let Some(ConfiguredModel { model, provider }) = model_registry.default_model() else {
return;
};
if provider.must_accept_terms(cx) {
cx.notify();
return;
}
let model_registry = LanguageModelRegistry::read_global(cx);
let Some(model) = model_registry.active_model() else {
return;
};
let user_message = self.editor.update(cx, |editor, cx| {
let text = editor.text(cx);
editor.clear(window, cx);
@@ -226,7 +222,8 @@ impl MessageEditor {
let thread = self.thread.clone();
let context_store = self.context_store.clone();
let checkpoint = self.project.read(cx).git_store().read(cx).checkpoint(cx);
let git_store = self.project.read(cx).git_store().clone();
let checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
cx.spawn(async move |this, cx| {
let checkpoint = checkpoint.await.ok();
@@ -240,14 +237,14 @@ impl MessageEditor {
cx.emit(ThreadEvent::ShowError(load_error));
}
})
.ok();
.log_err();
thread
.update(cx, |thread, cx| {
let context = context_store.read(cx).context().clone();
thread.insert_user_message(user_message, context, checkpoint, cx);
})
.ok();
.log_err();
if let Some(wait_for_summaries) = context_store
.update(cx, |context_store, cx| context_store.wait_for_summaries(cx))
@@ -257,7 +254,7 @@ impl MessageEditor {
this.waiting_for_summaries_to_send = true;
cx.notify();
})
.ok();
.log_err();
wait_for_summaries.await;
@@ -265,7 +262,7 @@ impl MessageEditor {
this.waiting_for_summaries_to_send = false;
cx.notify();
})
.ok();
.log_err();
}
// Send to model after summaries are done
@@ -273,7 +270,7 @@ impl MessageEditor {
.update(cx, |thread, cx| {
thread.send_to_model(model, request_kind, cx);
})
.ok();
.log_err();
})
.detach();
}
@@ -320,6 +317,19 @@ impl MessageEditor {
fn handle_review_click(&self, window: &mut Window, cx: &mut Context<Self>) {
AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err();
}
fn handle_file_click(
&self,
buffer: Entity<Buffer>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Ok(diff) = AgentDiff::deploy(self.thread.clone(), self.workspace.clone(), window, cx)
{
let path_key = multi_buffer::PathKey::for_buffer(&buffer, cx);
diff.update(cx, |diff, cx| diff.move_to_path(path_key, window, cx));
}
}
}
impl Focusable for MessageEditor {
@@ -338,24 +348,10 @@ impl Render for MessageEditor {
let thread = self.thread.read(cx);
let is_generating = thread.is_generating();
let is_too_long = thread.is_getting_too_long(cx);
let total_token_usage = thread.total_token_usage(cx);
let is_model_selected = self.is_model_selected(cx);
let is_editor_empty = self.is_editor_empty(cx);
let submit_label_color = if is_editor_empty {
Color::Muted
} else {
Color::Default
};
let vim_mode_enabled = VimModeSetting::get_global(cx).0;
let platform = PlatformStyle::platform();
let linux = platform == PlatformStyle::Linux;
let windows = platform == PlatformStyle::Windows;
let button_width = if linux || windows || vim_mode_enabled {
px(82.)
} else {
px(64.)
};
let is_edit_changes_expanded = self.edits_expanded;
let action_log = self.thread.read(cx).action_log();
let changed_buffers = action_log.read(cx).changed_buffers(cx);
@@ -403,64 +399,6 @@ impl Render for MessageEditor {
),
)
})
.when(is_generating, |parent| {
let focus_handle = self.editor.focus_handle(cx).clone();
parent.child(
h_flex().py_3().w_full().justify_center().child(
h_flex()
.flex_none()
.pl_2()
.pr_1()
.py_1()
.bg(editor_bg_color)
.border_1()
.border_color(cx.theme().colors().border_variant)
.rounded_lg()
.shadow_md()
.gap_1()
.child(
Icon::new(IconName::ArrowCircle)
.size(IconSize::XSmall)
.color(Color::Muted)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(gpui::Transformation::rotate(
gpui::percentage(delta),
))
},
),
)
.child(
Label::new("Generating…")
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.child(ui::Divider::vertical())
.child(
Button::new("cancel-generation", "Cancel")
.label_size(LabelSize::XSmall)
.key_binding(
KeyBinding::for_action_in(
&editor::actions::Cancel,
&focus_handle,
window,
cx,
)
.map(|kb| kb.size(rems_from_px(10.))),
)
.on_click(move |_event, window, cx| {
focus_handle.dispatch_action(
&editor::actions::Cancel,
window,
cx,
);
}),
),
),
)
})
.when(changed_buffers_count > 0, |parent| {
parent.child(
v_flex()
@@ -478,18 +416,23 @@ impl Render for MessageEditor {
}])
.child(
h_flex()
.id("edits-container")
.cursor_pointer()
.p_1p5()
.justify_between()
.when(self.edits_expanded, |this| {
.when(is_edit_changes_expanded, |this| {
this.border_b_1().border_color(border_color)
})
.on_click(cx.listener(|this, _, window, cx| {
this.handle_review_click(window, cx)
}))
.child(
h_flex()
.gap_1()
.child(
Disclosure::new(
"edits-disclosure",
self.edits_expanded,
is_edit_changes_expanded,
)
.on_click(
cx.listener(|this, _ev, _window, cx| {
@@ -539,9 +482,9 @@ impl Render for MessageEditor {
})),
),
)
.when(self.edits_expanded, |parent| {
.when(is_edit_changes_expanded, |parent| {
parent.child(
v_flex().bg(cx.theme().colors().editor_background).children(
v_flex().children(
changed_buffers.into_iter().enumerate().flat_map(
|(index, (buffer, _diff))| {
let file = buffer.read(cx).file()?;
@@ -555,7 +498,7 @@ impl Render for MessageEditor {
} else {
Some(
Label::new(format!(
"{}{}",
"/{}{}",
parent_str,
std::path::MAIN_SEPARATOR_STR
))
@@ -583,60 +526,105 @@ impl Render for MessageEditor {
.size(IconSize::Small)
});
let element = div()
let hover_color = cx.theme()
.colors()
.element_background
.blend(cx.theme().colors().editor_foreground.opacity(0.025));
let overlay_gradient = linear_gradient(
90.,
linear_color_stop(
editor_bg_color,
1.,
),
linear_color_stop(
editor_bg_color
.opacity(0.2),
0.,
),
);
let overlay_gradient_hover = linear_gradient(
90.,
linear_color_stop(
hover_color,
1.,
),
linear_color_stop(
hover_color
.opacity(0.2),
0.,
),
);
let element = h_flex()
.group("edited-code")
.id(("file-container", index))
.cursor_pointer()
.relative()
.py_1()
.px_2()
.pl_2()
.pr_1()
.gap_2()
.justify_between()
.bg(cx.theme().colors().editor_background)
.hover(|style| style.bg(hover_color))
.when(index + 1 < changed_buffers_count, |parent| {
parent.border_color(border_color).border_b_1()
})
.child(
h_flex()
.gap_2()
.justify_between()
.id("file-name")
.pr_8()
.gap_1p5()
.max_w_full()
.overflow_x_scroll()
.child(file_icon)
.child(
h_flex()
.id("file-container")
.pr_8()
.gap_1p5()
.max_w_full()
.overflow_x_scroll()
.child(file_icon)
.child(
h_flex()
.children(parent_label)
.children(name_label),
) // TODO: show lines changed
.child(
Label::new("+")
.color(Color::Created),
)
.child(
Label::new("-")
.color(Color::Deleted),
),
.gap_0p5()
.children(name_label)
.children(parent_label)
) // TODO: show lines changed
.child(
Label::new("+")
.color(Color::Created),
)
.child(
div()
.h_full()
.absolute()
.w_8()
.bottom_0()
.right_0()
.bg(linear_gradient(
90.,
linear_color_stop(
editor_bg_color,
1.,
),
linear_color_stop(
editor_bg_color
.opacity(0.2),
0.,
),
)),
Label::new("-")
.color(Color::Deleted),
),
);
)
.child(
div().visible_on_hover("edited-code").child(
Button::new("review", "Review")
.label_size(LabelSize::Small)
.on_click({
let buffer = buffer.clone();
cx.listener(move |this, _, window, cx| {
this.handle_file_click(buffer.clone(), window, cx);
})
})
)
)
.child(
div()
.id("gradient-overlay")
.absolute()
.h_5_6()
.w_12()
.bottom_0()
.right(px(52.))
.bg(overlay_gradient)
.group_hover("edited-code", |style| style.bg(overlay_gradient_hover))
,
)
.on_click({
let buffer = buffer.clone();
cx.listener(move |this, _, window, cx| {
this.handle_file_click(buffer.clone(), window, cx);
})
});
Some(element)
},
@@ -696,7 +684,6 @@ impl Render for MessageEditor {
..Default::default()
},
).into_any()
})
.child(
PopoverMenu::new("inline-context-picker")
@@ -704,7 +691,6 @@ impl Render for MessageEditor {
inline_context_picker.update(cx, |this, cx| {
this.init(window, cx);
});
Some(inline_context_picker.clone())
})
.attach(gpui::Corner::TopLeft)
@@ -721,65 +707,77 @@ impl Render for MessageEditor {
.justify_between()
.child(h_flex().gap_2().child(self.profile_selector.clone()))
.child(
h_flex().gap_1().child(self.model_selector.clone()).child(
ButtonLike::new("submit-message")
.width(button_width.into())
.style(ButtonStyle::Filled)
.disabled(
is_editor_empty
|| !is_model_selected
|| is_generating
|| self.waiting_for_summaries_to_send
)
.child(
h_flex()
.w_full()
.justify_between()
.child(
Label::new("Submit")
.size(LabelSize::Small)
.color(submit_label_color),
)
.children(
KeyBinding::for_action_in(
&Chat,
&focus_handle,
window,
cx,
h_flex().gap_1().child(self.model_selector.clone())
.map(|parent| {
if is_generating {
parent.child(
IconButton::new("stop-generation", IconName::StopFilled)
.icon_color(Color::Error)
.style(ButtonStyle::Tinted(ui::TintColor::Error))
.tooltip(move |window, cx| {
Tooltip::for_action(
"Stop Generation",
&editor::actions::Cancel,
window,
cx,
)
})
.on_click(move |_event, window, cx| {
focus_handle.dispatch_action(
&editor::actions::Cancel,
window,
cx,
);
})
.with_animation(
"pulsating-label",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 1.0)),
|icon_button, delta| icon_button.alpha(delta),
),
)
} else {
parent.child(
IconButton::new("send-message", IconName::Send)
.icon_color(Color::Accent)
.style(ButtonStyle::Filled)
.disabled(
is_editor_empty
|| !is_model_selected
|| self.waiting_for_summaries_to_send
)
.map(|binding| {
binding
.when(vim_mode_enabled, |kb| {
kb.size(rems_from_px(12.))
})
.into_any_element()
}),
),
)
.on_click(move |_event, window, cx| {
focus_handle.dispatch_action(&Chat, window, cx);
})
.when(is_editor_empty, |button| {
button.tooltip(Tooltip::text(
"Type a message to submit",
))
})
.when(is_generating, |button| {
button.tooltip(Tooltip::text(
"Cancel to submit a new message",
))
})
.when(!is_model_selected, |button| {
button.tooltip(Tooltip::text(
"Select a model to continue",
))
}),
),
.on_click(move |_event, window, cx| {
focus_handle.dispatch_action(&Chat, window, cx);
})
.when(!is_editor_empty && is_model_selected, |button| {
button.tooltip(move |window, cx| {
Tooltip::for_action(
"Send",
&Chat,
window,
cx,
)
})
})
.when(is_editor_empty, |button| {
button.tooltip(Tooltip::text(
"Type a message to submit",
))
})
.when(!is_model_selected, |button| {
button.tooltip(Tooltip::text(
"Select a model to continue",
))
})
)
}
})
),
),
)
)
.when(is_too_long, |parent| {
.when(total_token_usage.ratio != TokenUsageRatio::Normal, |parent| {
parent.child(
h_flex()
.p_2()

View File

@@ -130,8 +130,8 @@ impl Render for ProfileSelector {
let model_registry = LanguageModelRegistry::read_global(cx);
let supports_tools = model_registry
.active_model()
.map_or(false, |model| model.supports_tools());
.default_model()
.map_or(false, |default| default.model.supports_tools());
let icon = match profile_id.as_str() {
"write" => IconName::Pencil,

View File

@@ -2,9 +2,11 @@ use crate::inline_prompt_editor::CodegenStatus;
use client::telemetry::Telemetry;
use futures::{SinkExt, StreamExt, channel::mpsc};
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task};
use language_model::{LanguageModelRegistry, LanguageModelRequest, report_assistant_event};
use language_model::{
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, report_assistant_event,
};
use std::{sync::Arc, time::Instant};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use terminal::Terminal;
pub struct TerminalCodegen {
@@ -31,7 +33,9 @@ impl TerminalCodegen {
}
pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut Context<Self>) {
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
else {
return;
};
@@ -75,7 +79,7 @@ impl TerminalCodegen {
let error_message = result.as_ref().err().map(|error| error.to_string());
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
kind: AssistantKind::InlineTerminal,
message_id,

View File

@@ -13,12 +13,12 @@ use fs::Fs;
use gpui::{App, Entity, Focusable, Global, Subscription, UpdateGlobal, WeakEntity};
use language::Buffer;
use language_model::{
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
report_assistant_event,
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
Role, report_assistant_event,
};
use prompt_store::PromptBuilder;
use std::sync::Arc;
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use terminal_view::TerminalView;
use ui::prelude::*;
use util::ResultExt;
@@ -286,11 +286,13 @@ impl TerminalInlineAssistant {
})
.log_err();
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
if let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
{
let codegen = assist.codegen.read(cx);
let executor = cx.background_executor().clone();
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
kind: AssistantKind::InlineTerminal,
message_id: codegen.message_id.clone(),

View File

@@ -7,17 +7,17 @@ use anyhow::{Context as _, Result, anyhow};
use assistant_settings::AssistantSettings;
use assistant_tool::{ActionLog, Tool, ToolWorkingSet};
use chrono::{DateTime, Utc};
use collections::{BTreeMap, HashMap, HashSet};
use collections::{BTreeMap, HashMap};
use fs::Fs;
use futures::future::Shared;
use futures::{FutureExt, StreamExt as _};
use git::repository::DiffType;
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
use language_model::{
LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult,
LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, PaymentRequiredError,
Role, StopReason, TokenUsage,
ConfiguredModel, LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry,
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool,
LanguageModelToolResult, LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent,
PaymentRequiredError, Role, StopReason, TokenUsage,
};
use project::git_store::{GitStore, GitStoreCheckpoint, RepositoryState};
use project::{Project, Worktree};
@@ -30,12 +30,12 @@ use settings::Settings;
use util::{ResultExt as _, TryFutureExt as _, maybe, post_inc};
use uuid::Uuid;
use crate::context::{AssistantContext, ContextId, attach_context_to_message};
use crate::context::{AssistantContext, ContextId, format_context_as_string};
use crate::thread_store::{
SerializedMessage, SerializedMessageSegment, SerializedThread, SerializedToolResult,
SerializedToolUse,
};
use crate::tool_use::{PendingToolUse, ToolUse, ToolUseState};
use crate::tool_use::{PendingToolUse, ToolUse, ToolUseState, USING_TOOL_MARKER};
#[derive(Debug, Clone, Copy)]
pub enum RequestKind {
@@ -82,9 +82,16 @@ pub struct Message {
pub id: MessageId,
pub role: Role,
pub segments: Vec<MessageSegment>,
pub context: String,
}
impl Message {
/// Returns whether the message contains any meaningful text that should be displayed
/// The model sometimes runs tool without producing any text or just a marker ([`USING_TOOL_MARKER`])
pub fn should_display_content(&self) -> bool {
self.segments.iter().all(|segment| segment.should_display())
}
pub fn push_thinking(&mut self, text: &str) {
if let Some(MessageSegment::Thinking(segment)) = self.segments.last_mut() {
segment.push_str(text);
@@ -104,6 +111,11 @@ impl Message {
pub fn to_string(&self) -> String {
let mut result = String::new();
if !self.context.is_empty() {
result.push_str(&self.context);
}
for segment in &self.segments {
match segment {
MessageSegment::Text(text) => result.push_str(text),
@@ -114,11 +126,12 @@ impl Message {
}
}
}
result
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageSegment {
Text(String),
Thinking(String),
@@ -131,6 +144,16 @@ impl MessageSegment {
Self::Thinking(text) => text,
}
}
pub fn should_display(&self) -> bool {
// We add USING_TOOL_MARKER when making a request that includes tool uses
// without non-whitespace text around them, and this can cause the model
// to mimic the pattern, so we consider those segments not displayable.
match self {
Self::Text(text) => text.is_empty() || text.trim() == USING_TOOL_MARKER,
Self::Thinking(text) => text.is_empty() || text.trim() == USING_TOOL_MARKER,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -198,6 +221,21 @@ pub enum DetailedSummaryState {
},
}
#[derive(Default)]
pub struct TotalTokenUsage {
pub total: usize,
pub max: usize,
pub ratio: TokenUsageRatio,
}
#[derive(Default, PartialEq, Eq)]
pub enum TokenUsageRatio {
#[default]
Normal,
Warning,
Exceeded,
}
/// A thread of conversation with the LLM.
pub struct Thread {
id: ThreadId,
@@ -252,7 +290,7 @@ impl Thread {
last_restore_checkpoint: None,
pending_checkpoint: None,
tool_use: ToolUseState::new(tools.clone()),
action_log: cx.new(|_| ActionLog::new()),
action_log: cx.new(|_| ActionLog::new(project.clone())),
initial_project_snapshot: {
let project_snapshot = Self::project_snapshot(project, cx);
cx.foreground_executor()
@@ -304,6 +342,7 @@ impl Thread {
}
})
.collect(),
context: message.context,
})
.collect(),
next_message_id,
@@ -315,11 +354,11 @@ impl Thread {
pending_completions: Vec::new(),
last_restore_checkpoint: None,
pending_checkpoint: None,
project,
project: project.clone(),
prompt_builder,
tools,
tool_use,
action_log: cx.new(|_| ActionLog::new()),
action_log: cx.new(|_| ActionLog::new(project)),
initial_project_snapshot: Task::ready(serialized.initial_project_snapshot).shared(),
cumulative_token_usage: serialized.cumulative_token_usage,
feedback: None,
@@ -346,14 +385,28 @@ impl Thread {
self.summary.clone()
}
pub const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread");
pub fn summary_or_default(&self) -> SharedString {
const DEFAULT: SharedString = SharedString::new_static("New Thread");
self.summary.clone().unwrap_or(DEFAULT)
self.summary.clone().unwrap_or(Self::DEFAULT_SUMMARY)
}
pub fn set_summary(&mut self, summary: impl Into<SharedString>, cx: &mut Context<Self>) {
self.summary = Some(summary.into());
cx.emit(ThreadEvent::SummaryChanged);
pub fn set_summary(&mut self, new_summary: impl Into<SharedString>, cx: &mut Context<Self>) {
let Some(current_summary) = &self.summary else {
// Don't allow setting summary until generated
return;
};
let mut new_summary = new_summary.into();
if new_summary.is_empty() {
new_summary = Self::DEFAULT_SUMMARY;
}
if current_summary != &new_summary {
self.summary = Some(new_summary);
cx.emit(ThreadEvent::SummaryChanged);
}
}
pub fn latest_detailed_summary_or_text(&self) -> SharedString {
@@ -418,11 +471,11 @@ impl Thread {
cx.emit(ThreadEvent::CheckpointChanged);
cx.notify();
let project = self.project.read(cx);
let restore = project
.git_store()
.read(cx)
.restore_checkpoint(checkpoint.git_checkpoint.clone(), cx);
let git_store = self.project().read(cx).git_store().clone();
let restore = git_store.update(cx, |git_store, cx| {
git_store.restore_checkpoint(checkpoint.git_checkpoint.clone(), cx)
});
cx.spawn(async move |this, cx| {
let result = restore.await;
this.update(cx, |this, cx| {
@@ -453,11 +506,11 @@ impl Thread {
};
let git_store = self.project.read(cx).git_store().clone();
let final_checkpoint = git_store.read(cx).checkpoint(cx);
let final_checkpoint = git_store.update(cx, |git_store, cx| git_store.checkpoint(cx));
cx.spawn(async move |this, cx| match final_checkpoint.await {
Ok(final_checkpoint) => {
let equal = git_store
.read_with(cx, |store, cx| {
.update(cx, |store, cx| {
store.compare_checkpoints(
pending_checkpoint.git_checkpoint.clone(),
final_checkpoint.clone(),
@@ -469,7 +522,7 @@ impl Thread {
if equal {
git_store
.read_with(cx, |store, cx| {
.update(cx, |store, cx| {
store.delete_checkpoint(pending_checkpoint.git_checkpoint, cx)
})?
.detach();
@@ -480,7 +533,7 @@ impl Thread {
}
git_store
.read_with(cx, |store, cx| {
.update(cx, |store, cx| {
store.delete_checkpoint(final_checkpoint, cx)
})?
.detach();
@@ -564,15 +617,58 @@ impl Thread {
git_checkpoint: Option<GitStoreCheckpoint>,
cx: &mut Context<Self>,
) -> MessageId {
let message_id =
self.insert_message(Role::User, vec![MessageSegment::Text(text.into())], cx);
let context_ids = context
let text = text.into();
let message_id = self.insert_message(Role::User, vec![MessageSegment::Text(text)], cx);
// Filter out contexts that have already been included in previous messages
let new_context: Vec<_> = context
.into_iter()
.filter(|ctx| !self.context.contains_key(&ctx.id()))
.collect();
if !new_context.is_empty() {
if let Some(context_string) = format_context_as_string(new_context.iter(), cx) {
if let Some(message) = self.messages.iter_mut().find(|m| m.id == message_id) {
message.context = context_string;
}
}
self.action_log.update(cx, |log, cx| {
// Track all buffers added as context
for ctx in &new_context {
match ctx {
AssistantContext::File(file_ctx) => {
log.buffer_added_as_context(file_ctx.context_buffer.buffer.clone(), cx);
}
AssistantContext::Directory(dir_ctx) => {
for context_buffer in &dir_ctx.context_buffers {
log.buffer_added_as_context(context_buffer.buffer.clone(), cx);
}
}
AssistantContext::Symbol(symbol_ctx) => {
log.buffer_added_as_context(
symbol_ctx.context_symbol.buffer.clone(),
cx,
);
}
AssistantContext::FetchedUrl(_) | AssistantContext::Thread(_) => {}
}
}
});
}
let context_ids = new_context
.iter()
.map(|context| context.id())
.collect::<Vec<_>>();
self.context
.extend(context.into_iter().map(|context| (context.id(), context)));
self.context.extend(
new_context
.into_iter()
.map(|context| (context.id(), context)),
);
self.context_by_message.insert(message_id, context_ids);
if let Some(git_checkpoint) = git_checkpoint {
self.pending_checkpoint = Some(ThreadCheckpoint {
message_id,
@@ -589,7 +685,12 @@ impl Thread {
cx: &mut Context<Self>,
) -> MessageId {
let id = self.next_message_id.post_inc();
self.messages.push(Message { id, role, segments });
self.messages.push(Message {
id,
role,
segments,
context: String::new(),
});
self.touch_updated_at();
cx.emit(ThreadEvent::MessageAdded(id));
id
@@ -695,6 +796,7 @@ impl Thread {
content: tool_result.content.clone(),
})
.collect(),
context: message.context.clone(),
})
.collect(),
initial_project_snapshot,
@@ -881,8 +983,6 @@ impl Thread {
log::error!("system_prompt_context not set.")
}
let mut added_context_ids = HashSet::<ContextId>::default();
for message in &self.messages {
let mut request_message = LanguageModelRequestMessage {
role: message.role,
@@ -903,23 +1003,6 @@ impl Thread {
}
}
// Attach context to this message if it's the first to reference it
if let Some(context_ids) = self.context_by_message.get(&message.id) {
let new_context_ids: Vec<_> = context_ids
.iter()
.filter(|id| !added_context_ids.contains(id))
.collect();
if !new_context_ids.is_empty() {
let referenced_context = new_context_ids
.iter()
.filter_map(|context_id| self.context.get(*context_id));
attach_context_to_message(&mut request_message, referenced_context, cx);
added_context_ids.extend(context_ids.iter());
}
}
if !message.segments.is_empty() {
request_message
.content
@@ -939,11 +1022,9 @@ impl Thread {
request.messages.push(request_message);
}
// Set a cache breakpoint at the second-to-last message.
// https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
let breakpoint_index = request.messages.len() - 2;
for (index, message) in request.messages.iter_mut().enumerate() {
message.cache = index == breakpoint_index;
if let Some(last) = request.messages.last_mut() {
last.cache = true;
}
self.attached_tracked_files_state(&mut request.messages, cx);
@@ -968,7 +1049,7 @@ impl Thread {
};
if stale_message.is_empty() {
write!(&mut stale_message, "{}", STALE_FILES_HEADER).ok();
write!(&mut stale_message, "{}\n", STALE_FILES_HEADER).ok();
}
writeln!(&mut stale_message, "- {}", file.path().display()).ok();
@@ -1083,32 +1164,15 @@ impl Thread {
}
}
LanguageModelCompletionEvent::ToolUse(tool_use) => {
let last_assistant_message = thread
let last_assistant_message_id = thread
.messages
.iter_mut()
.rfind(|message| message.role == Role::Assistant);
.rfind(|message| message.role == Role::Assistant)
.map(|message| message.id)
.unwrap_or_else(|| {
thread.insert_message(Role::Assistant, vec![], cx)
});
let last_assistant_message_id =
if let Some(message) = last_assistant_message {
if let Some(segment) = message.segments.first_mut() {
let text = segment.text_mut();
if text.is_empty() {
text.push_str("Using tool...");
}
} else {
message.segments.push(MessageSegment::Text(
"Using tool...".to_string(),
));
}
message.id
} else {
thread.insert_message(
Role::Assistant,
vec![MessageSegment::Text("Using tool...".to_string())],
cx,
)
};
thread.tool_use.request_tool_use(
last_assistant_message_id,
tool_use,
@@ -1200,14 +1264,11 @@ impl Thread {
}
pub fn summarize(&mut self, cx: &mut Context<Self>) {
let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
return;
};
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else {
return;
};
if !provider.is_authenticated(cx) {
if !model.provider.is_authenticated(cx) {
return;
}
@@ -1226,7 +1287,7 @@ impl Thread {
self.pending_summary = cx.spawn(async move |this, cx| {
async move {
let stream = model.stream_completion_text(request, &cx);
let stream = model.model.stream_completion_text(request, &cx);
let mut messages = stream.await?;
let mut new_summary = String::new();
@@ -1246,7 +1307,7 @@ impl Thread {
this.summary = Some(new_summary.into());
}
cx.emit(ThreadEvent::SummaryChanged);
cx.emit(ThreadEvent::SummaryGenerated);
})?;
anyhow::Ok(())
@@ -1270,8 +1331,8 @@ impl Thread {
_ => {}
}
let provider = LanguageModelRegistry::read_global(cx).active_provider()?;
let model = LanguageModelRegistry::read_global(cx).active_model()?;
let ConfiguredModel { model, provider } =
LanguageModelRegistry::read_global(cx).thread_summary_model()?;
if !provider.is_authenticated(cx) {
return None;
@@ -1426,6 +1487,7 @@ impl Thread {
tool_use_id.clone(),
tool_name,
output,
cx,
);
cx.emit(ThreadEvent::ToolFinished {
@@ -1439,17 +1501,7 @@ impl Thread {
})
}
pub fn attach_tool_results(
&mut self,
updated_context: Vec<AssistantContext>,
cx: &mut Context<Self>,
) {
self.context.extend(
updated_context
.into_iter()
.map(|context| (context.id(), context)),
);
pub fn attach_tool_results(&mut self, cx: &mut Context<Self>) {
// Insert a user message to contain the tool results.
self.insert_user_message(
// TODO: Sending up a user message without any content results in the model sending back
@@ -1599,10 +1651,10 @@ impl Thread {
.ok()
.flatten()
.map(|repo| {
repo.read_with(cx, |repo, _| {
repo.update(cx, |repo, _| {
let current_branch =
repo.branch.as_ref().map(|branch| branch.name.to_string());
repo.send_job(|state, _| async move {
repo.send_job(None, |state, _| async move {
let RepositoryState::Local { backend, .. } = state else {
return GitState {
remote_url: None,
@@ -1658,6 +1710,11 @@ impl Thread {
Role::System => "System",
}
)?;
if !message.context.is_empty() {
writeln!(markdown, "{}", message.context)?;
}
for segment in &message.segments {
match segment {
MessageSegment::Text(text) => writeln!(markdown, "{}\n", text)?,
@@ -1712,6 +1769,17 @@ impl Thread {
.update(cx, |action_log, cx| action_log.keep_all_edits(cx));
}
pub fn reject_edits_in_range(
&mut self,
buffer: Entity<language::Buffer>,
buffer_range: Range<language::Anchor>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.action_log.update(cx, |action_log, cx| {
action_log.reject_edits_in_range(buffer, buffer_range, cx)
})
}
pub fn action_log(&self) -> &Entity<ActionLog> {
&self.action_log
}
@@ -1724,26 +1792,33 @@ impl Thread {
self.cumulative_token_usage.clone()
}
pub fn is_getting_too_long(&self, cx: &App) -> bool {
pub fn total_token_usage(&self, cx: &App) -> TotalTokenUsage {
let model_registry = LanguageModelRegistry::read_global(cx);
let Some(model) = model_registry.active_model() else {
return false;
let Some(model) = model_registry.default_model() else {
return TotalTokenUsage::default();
};
let max_tokens = model.max_token_count();
let current_usage =
self.cumulative_token_usage.input_tokens + self.cumulative_token_usage.output_tokens;
let max = model.model.max_token_count();
#[cfg(debug_assertions)]
let warning_threshold: f32 = std::env::var("ZED_THREAD_WARNING_THRESHOLD")
.unwrap_or("0.9".to_string())
.unwrap_or("0.8".to_string())
.parse()
.unwrap();
#[cfg(not(debug_assertions))]
let warning_threshold: f32 = 0.9;
let warning_threshold: f32 = 0.8;
current_usage as f32 >= (max_tokens as f32 * warning_threshold)
let total = self.cumulative_token_usage.total_tokens() as usize;
let ratio = if total >= max {
TokenUsageRatio::Exceeded
} else if total as f32 / max as f32 >= warning_threshold {
TokenUsageRatio::Warning
} else {
TokenUsageRatio::Normal
};
TotalTokenUsage { total, max, ratio }
}
pub fn deny_tool_use(
@@ -1757,7 +1832,7 @@ impl Thread {
));
self.tool_use
.insert_tool_output(tool_use_id.clone(), tool_name, err);
.insert_tool_output(tool_use_id.clone(), tool_name, err, cx);
cx.emit(ThreadEvent::ToolFinished {
tool_use_id,
@@ -1787,6 +1862,7 @@ pub enum ThreadEvent {
MessageAdded(MessageId),
MessageEdited(MessageId),
MessageDeleted(MessageId),
SummaryGenerated,
SummaryChanged,
UsePendingTools,
ToolFinished {
@@ -1807,3 +1883,415 @@ struct PendingCompletion {
id: usize,
_task: Task<()>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ThreadStore, context_store::ContextStore, thread_store};
use assistant_settings::AssistantSettings;
use context_server::ContextServerSettings;
use editor::EditorSettings;
use gpui::TestAppContext;
use project::{FakeFs, Project};
use prompt_store::PromptBuilder;
use serde_json::json;
use settings::{Settings, SettingsStore};
use std::sync::Arc;
use theme::ThemeSettings;
use util::path;
use workspace::Workspace;
#[gpui::test]
async fn test_message_with_context(cx: &mut TestAppContext) {
init_test_settings(cx);
let project = create_test_project(
cx,
json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}),
)
.await;
let (_workspace, _thread_store, thread, context_store) =
setup_test_environment(cx, project.clone()).await;
add_file_to_context(&project, &context_store, "test/code.rs", cx)
.await
.unwrap();
let context =
context_store.update(cx, |store, _| store.context().first().cloned().unwrap());
// Insert user message with context
let message_id = thread.update(cx, |thread, cx| {
thread.insert_user_message("Please explain this code", vec![context], None, cx)
});
// Check content and context in message object
let message = thread.read_with(cx, |thread, _| thread.message(message_id).unwrap().clone());
// Use different path format strings based on platform for the test
#[cfg(windows)]
let path_part = r"test\code.rs";
#[cfg(not(windows))]
let path_part = "test/code.rs";
let expected_context = format!(
r#"
<context>
The following items were attached by the user. You don't need to use other tools to read them.
<files>
```rs {path_part}
fn main() {{
println!("Hello, world!");
}}
```
</files>
</context>
"#
);
assert_eq!(message.role, Role::User);
assert_eq!(message.segments.len(), 1);
assert_eq!(
message.segments[0],
MessageSegment::Text("Please explain this code".to_string())
);
assert_eq!(message.context, expected_context);
// Check message in request
let request = thread.read_with(cx, |thread, cx| {
thread.to_completion_request(RequestKind::Chat, cx)
});
assert_eq!(request.messages.len(), 1);
let expected_full_message = format!("{}Please explain this code", expected_context);
assert_eq!(request.messages[0].string_contents(), expected_full_message);
}
#[gpui::test]
async fn test_only_include_new_contexts(cx: &mut TestAppContext) {
init_test_settings(cx);
let project = create_test_project(
cx,
json!({
"file1.rs": "fn function1() {}\n",
"file2.rs": "fn function2() {}\n",
"file3.rs": "fn function3() {}\n",
}),
)
.await;
let (_, _thread_store, thread, context_store) =
setup_test_environment(cx, project.clone()).await;
// Open files individually
add_file_to_context(&project, &context_store, "test/file1.rs", cx)
.await
.unwrap();
add_file_to_context(&project, &context_store, "test/file2.rs", cx)
.await
.unwrap();
add_file_to_context(&project, &context_store, "test/file3.rs", cx)
.await
.unwrap();
// Get the context objects
let contexts = context_store.update(cx, |store, _| store.context().clone());
assert_eq!(contexts.len(), 3);
// First message with context 1
let message1_id = thread.update(cx, |thread, cx| {
thread.insert_user_message("Message 1", vec![contexts[0].clone()], None, cx)
});
// Second message with contexts 1 and 2 (context 1 should be skipped as it's already included)
let message2_id = thread.update(cx, |thread, cx| {
thread.insert_user_message(
"Message 2",
vec![contexts[0].clone(), contexts[1].clone()],
None,
cx,
)
});
// Third message with all three contexts (contexts 1 and 2 should be skipped)
let message3_id = thread.update(cx, |thread, cx| {
thread.insert_user_message(
"Message 3",
vec![
contexts[0].clone(),
contexts[1].clone(),
contexts[2].clone(),
],
None,
cx,
)
});
// Check what contexts are included in each message
let (message1, message2, message3) = thread.read_with(cx, |thread, _| {
(
thread.message(message1_id).unwrap().clone(),
thread.message(message2_id).unwrap().clone(),
thread.message(message3_id).unwrap().clone(),
)
});
// First message should include context 1
assert!(message1.context.contains("file1.rs"));
// Second message should include only context 2 (not 1)
assert!(!message2.context.contains("file1.rs"));
assert!(message2.context.contains("file2.rs"));
// Third message should include only context 3 (not 1 or 2)
assert!(!message3.context.contains("file1.rs"));
assert!(!message3.context.contains("file2.rs"));
assert!(message3.context.contains("file3.rs"));
// Check entire request to make sure all contexts are properly included
let request = thread.read_with(cx, |thread, cx| {
thread.to_completion_request(RequestKind::Chat, cx)
});
// The request should contain all 3 messages
assert_eq!(request.messages.len(), 3);
// Check that the contexts are properly formatted in each message
assert!(request.messages[0].string_contents().contains("file1.rs"));
assert!(!request.messages[0].string_contents().contains("file2.rs"));
assert!(!request.messages[0].string_contents().contains("file3.rs"));
assert!(!request.messages[1].string_contents().contains("file1.rs"));
assert!(request.messages[1].string_contents().contains("file2.rs"));
assert!(!request.messages[1].string_contents().contains("file3.rs"));
assert!(!request.messages[2].string_contents().contains("file1.rs"));
assert!(!request.messages[2].string_contents().contains("file2.rs"));
assert!(request.messages[2].string_contents().contains("file3.rs"));
}
#[gpui::test]
async fn test_message_without_files(cx: &mut TestAppContext) {
init_test_settings(cx);
let project = create_test_project(
cx,
json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}),
)
.await;
let (_, _thread_store, thread, _context_store) =
setup_test_environment(cx, project.clone()).await;
// Insert user message without any context (empty context vector)
let message_id = thread.update(cx, |thread, cx| {
thread.insert_user_message("What is the best way to learn Rust?", vec![], None, cx)
});
// Check content and context in message object
let message = thread.read_with(cx, |thread, _| thread.message(message_id).unwrap().clone());
// Context should be empty when no files are included
assert_eq!(message.role, Role::User);
assert_eq!(message.segments.len(), 1);
assert_eq!(
message.segments[0],
MessageSegment::Text("What is the best way to learn Rust?".to_string())
);
assert_eq!(message.context, "");
// Check message in request
let request = thread.read_with(cx, |thread, cx| {
thread.to_completion_request(RequestKind::Chat, cx)
});
assert_eq!(request.messages.len(), 1);
assert_eq!(
request.messages[0].string_contents(),
"What is the best way to learn Rust?"
);
// Add second message, also without context
let message2_id = thread.update(cx, |thread, cx| {
thread.insert_user_message("Are there any good books?", vec![], None, cx)
});
let message2 =
thread.read_with(cx, |thread, _| thread.message(message2_id).unwrap().clone());
assert_eq!(message2.context, "");
// Check that both messages appear in the request
let request = thread.read_with(cx, |thread, cx| {
thread.to_completion_request(RequestKind::Chat, cx)
});
assert_eq!(request.messages.len(), 2);
assert_eq!(
request.messages[0].string_contents(),
"What is the best way to learn Rust?"
);
assert_eq!(
request.messages[1].string_contents(),
"Are there any good books?"
);
}
#[gpui::test]
async fn test_stale_buffer_notification(cx: &mut TestAppContext) {
init_test_settings(cx);
let project = create_test_project(
cx,
json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}),
)
.await;
let (_workspace, _thread_store, thread, context_store) =
setup_test_environment(cx, project.clone()).await;
// Open buffer and add it to context
let buffer = add_file_to_context(&project, &context_store, "test/code.rs", cx)
.await
.unwrap();
let context =
context_store.update(cx, |store, _| store.context().first().cloned().unwrap());
// Insert user message with the buffer as context
thread.update(cx, |thread, cx| {
thread.insert_user_message("Explain this code", vec![context], None, cx)
});
// Create a request and check that it doesn't have a stale buffer warning yet
let initial_request = thread.read_with(cx, |thread, cx| {
thread.to_completion_request(RequestKind::Chat, cx)
});
// Make sure we don't have a stale file warning yet
let has_stale_warning = initial_request.messages.iter().any(|msg| {
msg.string_contents()
.contains("These files changed since last read:")
});
assert!(
!has_stale_warning,
"Should not have stale buffer warning before buffer is modified"
);
// Modify the buffer
buffer.update(cx, |buffer, cx| {
// Find a position at the end of line 1
buffer.edit(
[(1..1, "\n println!(\"Added a new line\");\n")],
None,
cx,
);
});
// Insert another user message without context
thread.update(cx, |thread, cx| {
thread.insert_user_message("What does the code do now?", vec![], None, cx)
});
// Create a new request and check for the stale buffer warning
let new_request = thread.read_with(cx, |thread, cx| {
thread.to_completion_request(RequestKind::Chat, cx)
});
// We should have a stale file warning as the last message
let last_message = new_request
.messages
.last()
.expect("Request should have messages");
// The last message should be the stale buffer notification
assert_eq!(last_message.role, Role::User);
// Check the exact content of the message
let expected_content = "These files changed since last read:\n- code.rs\n";
assert_eq!(
last_message.string_contents(),
expected_content,
"Last message should be exactly the stale buffer notification"
);
}
fn init_test_settings(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
AssistantSettings::register(cx);
thread_store::init(cx);
workspace::init_settings(cx);
ThemeSettings::register(cx);
ContextServerSettings::register(cx);
EditorSettings::register(cx);
});
}
// Helper to create a test project with test files
async fn create_test_project(
cx: &mut TestAppContext,
files: serde_json::Value,
) -> Entity<Project> {
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/test"), files).await;
Project::test(fs, [path!("/test").as_ref()], cx).await
}
async fn setup_test_environment(
cx: &mut TestAppContext,
project: Entity<Project>,
) -> (
Entity<Workspace>,
Entity<ThreadStore>,
Entity<Thread>,
Entity<ContextStore>,
) {
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let thread_store = cx.update(|_, cx| {
ThreadStore::new(
project.clone(),
Arc::default(),
Arc::new(PromptBuilder::new(None).unwrap()),
cx,
)
.unwrap()
});
let thread = thread_store.update(cx, |store, cx| store.create_thread(cx));
let context_store = cx.new(|_cx| ContextStore::new(workspace.downgrade(), None));
(workspace, thread_store, thread, context_store)
}
async fn add_file_to_context(
project: &Entity<Project>,
context_store: &Entity<ContextStore>,
path: &str,
cx: &mut TestAppContext,
) -> Result<Entity<language::Buffer>> {
let buffer_path = project
.read_with(cx, |project, cx| project.find_project_path(path, cx))
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(buffer_path, cx))
.await
.unwrap();
context_store
.update(cx, |store, cx| {
store.add_file_from_buffer(buffer.clone(), cx)
})
.await?;
Ok(buffer)
}
}

View File

@@ -1,52 +1,179 @@
use std::sync::Arc;
use assistant_context_editor::SavedContextMetadata;
use editor::{Editor, EditorEvent};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
App, Entity, FocusHandle, Focusable, ScrollStrategy, UniformListScrollHandle, WeakEntity,
uniform_list,
App, Entity, FocusHandle, Focusable, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity,
Window, uniform_list,
};
use time::{OffsetDateTime, UtcOffset};
use ui::{IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*};
use ui::{HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, prelude::*};
use util::ResultExt;
use crate::history_store::{HistoryEntry, HistoryStore};
use crate::thread_store::SerializedThreadMetadata;
use crate::{AssistantPanel, RemoveSelectedThread};
pub struct ThreadHistory {
focus_handle: FocusHandle,
assistant_panel: WeakEntity<AssistantPanel>,
history_store: Entity<HistoryStore>,
scroll_handle: UniformListScrollHandle,
selected_index: usize,
search_query: SharedString,
search_editor: Entity<Editor>,
all_entries: Arc<Vec<HistoryEntry>>,
matches: Vec<StringMatch>,
_subscriptions: Vec<gpui::Subscription>,
_search_task: Option<Task<()>>,
}
impl ThreadHistory {
pub(crate) fn new(
assistant_panel: WeakEntity<AssistantPanel>,
history_store: Entity<HistoryStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let search_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Search threads...", cx);
editor
});
let search_editor_subscription =
cx.subscribe(&search_editor, |this, search_editor, event, cx| {
if let EditorEvent::BufferEdited = event {
let query = search_editor.read(cx).text(cx);
this.search_query = query.into();
this.update_search(cx);
}
});
let entries: Arc<Vec<_>> = history_store
.update(cx, |store, cx| store.entries(cx))
.into();
let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
this.update_all_entries(cx);
});
Self {
focus_handle: cx.focus_handle(),
assistant_panel,
history_store,
scroll_handle: UniformListScrollHandle::default(),
selected_index: 0,
search_query: SharedString::new_static(""),
all_entries: entries,
matches: Vec::new(),
search_editor,
_subscriptions: vec![search_editor_subscription, history_store_subscription],
_search_task: None,
}
}
fn update_all_entries(&mut self, cx: &mut Context<Self>) {
self.all_entries = self
.history_store
.update(cx, |store, cx| store.entries(cx))
.into();
self.matches.clear();
self.update_search(cx);
}
fn update_search(&mut self, cx: &mut Context<Self>) {
self._search_task.take();
if self.has_search_query() {
self.perform_search(cx);
} else {
self.matches.clear();
self.set_selected_index(0, cx);
cx.notify();
}
}
fn perform_search(&mut self, cx: &mut Context<Self>) {
let query = self.search_query.clone();
let all_entries = self.all_entries.clone();
let task = cx.spawn(async move |this, cx| {
let executor = cx.background_executor().clone();
let matches = cx
.background_spawn(async move {
let mut candidates = Vec::with_capacity(all_entries.len());
for (idx, entry) in all_entries.iter().enumerate() {
match entry {
HistoryEntry::Thread(thread) => {
candidates.push(StringMatchCandidate::new(idx, &thread.summary));
}
HistoryEntry::Context(context) => {
candidates.push(StringMatchCandidate::new(idx, &context.title));
}
}
}
const MAX_MATCHES: usize = 100;
fuzzy::match_strings(
&candidates,
&query,
false,
MAX_MATCHES,
&Default::default(),
executor,
)
.await
})
.await;
this.update(cx, |this, cx| {
this.matches = matches;
this.set_selected_index(0, cx);
cx.notify();
})
.log_err();
});
self._search_task = Some(task);
}
fn has_search_query(&self) -> bool {
!self.search_query.is_empty()
}
fn matched_count(&self) -> usize {
if self.has_search_query() {
self.matches.len()
} else {
self.all_entries.len()
}
}
fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
if self.has_search_query() {
self.matches
.get(ix)
.and_then(|m| self.all_entries.get(m.candidate_id))
} else {
self.all_entries.get(ix)
}
}
pub fn select_previous(
&mut self,
_: &menu::SelectPrevious,
window: &mut Window,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let count = self
.history_store
.update(cx, |this, cx| this.entry_count(cx));
let count = self.matched_count();
if count > 0 {
if self.selected_index == 0 {
self.set_selected_index(count - 1, window, cx);
self.set_selected_index(count - 1, cx);
} else {
self.set_selected_index(self.selected_index - 1, window, cx);
self.set_selected_index(self.selected_index - 1, cx);
}
}
}
@@ -54,40 +181,39 @@ impl ThreadHistory {
pub fn select_next(
&mut self,
_: &menu::SelectNext,
window: &mut Window,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let count = self
.history_store
.update(cx, |this, cx| this.entry_count(cx));
let count = self.matched_count();
if count > 0 {
if self.selected_index == count - 1 {
self.set_selected_index(0, window, cx);
self.set_selected_index(0, cx);
} else {
self.set_selected_index(self.selected_index + 1, window, cx);
self.set_selected_index(self.selected_index + 1, cx);
}
}
}
fn select_first(&mut self, _: &menu::SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
let count = self
.history_store
.update(cx, |this, cx| this.entry_count(cx));
fn select_first(
&mut self,
_: &menu::SelectFirst,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let count = self.matched_count();
if count > 0 {
self.set_selected_index(0, window, cx);
self.set_selected_index(0, cx);
}
}
fn select_last(&mut self, _: &menu::SelectLast, window: &mut Window, cx: &mut Context<Self>) {
let count = self
.history_store
.update(cx, |this, cx| this.entry_count(cx));
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
let count = self.matched_count();
if count > 0 {
self.set_selected_index(count - 1, window, cx);
self.set_selected_index(count - 1, cx);
}
}
fn set_selected_index(&mut self, index: usize, _window: &mut Window, cx: &mut Context<Self>) {
fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
self.selected_index = index;
self.scroll_handle
.scroll_to_item(index, ScrollStrategy::Top);
@@ -95,23 +221,21 @@ impl ThreadHistory {
}
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
let entries = self.history_store.update(cx, |this, cx| this.entries(cx));
if let Some(entry) = entries.get(self.selected_index) {
match entry {
HistoryEntry::Thread(thread) => {
self.assistant_panel
.update(cx, move |this, cx| this.open_thread(&thread.id, window, cx))
.ok();
}
if let Some(entry) = self.get_match(self.selected_index) {
let task_result = match entry {
HistoryEntry::Thread(thread) => self
.assistant_panel
.update(cx, move |this, cx| this.open_thread(&thread.id, window, cx)),
HistoryEntry::Context(context) => {
self.assistant_panel
.update(cx, move |this, cx| {
this.open_saved_prompt_editor(context.path.clone(), window, cx)
})
.ok();
self.assistant_panel.update(cx, move |this, cx| {
this.open_saved_prompt_editor(context.path.clone(), window, cx)
})
}
}
};
if let Some(task) = task_result.log_err() {
task.detach_and_log_err(cx);
};
cx.notify();
}
@@ -123,25 +247,19 @@ impl ThreadHistory {
_window: &mut Window,
cx: &mut Context<Self>,
) {
let entries = self.history_store.update(cx, |this, cx| this.entries(cx));
if let Some(entry) = self.get_match(self.selected_index) {
let task_result = match entry {
HistoryEntry::Thread(thread) => self
.assistant_panel
.update(cx, |this, cx| this.delete_thread(&thread.id, cx)),
HistoryEntry::Context(context) => self
.assistant_panel
.update(cx, |this, cx| this.delete_context(context.path.clone(), cx)),
};
if let Some(entry) = entries.get(self.selected_index) {
match entry {
HistoryEntry::Thread(thread) => {
self.assistant_panel
.update(cx, |this, cx| {
this.delete_thread(&thread.id, cx);
})
.ok();
}
HistoryEntry::Context(context) => {
self.assistant_panel
.update(cx, |this, cx| {
this.delete_context(context.path.clone(), cx);
})
.ok();
}
}
if let Some(task) = task_result.log_err() {
task.detach_and_log_err(cx);
};
cx.notify();
}
@@ -149,66 +267,109 @@ impl ThreadHistory {
}
impl Focusable for ThreadHistory {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.search_editor.focus_handle(cx)
}
}
impl Render for ThreadHistory {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let history_entries = self.history_store.update(cx, |this, cx| this.entries(cx));
let selected_index = self.selected_index;
v_flex()
.id("thread-history-container")
.key_context("ThreadHistory")
.track_focus(&self.focus_handle)
.overflow_y_scroll()
.size_full()
.p_1()
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::remove_selected_thread))
.map(|history| {
if history_entries.is_empty() {
history
.justify_center()
.when(!self.all_entries.is_empty(), |parent| {
parent.child(
h_flex()
.h(px(41.)) // Match the toolbar perfectly
.w_full()
.py_1()
.px_2()
.gap_2()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border)
.child(
Icon::new(IconName::MagnifyingGlass)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(self.search_editor.clone()),
)
})
.child({
let view = v_flex().overflow_hidden().flex_grow();
if self.all_entries.is_empty() {
view.justify_center()
.child(
h_flex().w_full().justify_center().child(
Label::new("You don't have any past threads yet.")
.size(LabelSize::Small),
),
)
} else if self.has_search_query() && self.matches.is_empty() {
view.justify_center().child(
h_flex().w_full().justify_center().child(
Label::new("No threads match your search.").size(LabelSize::Small),
),
)
} else {
history.child(
view.p_1().child(
uniform_list(
cx.entity().clone(),
"thread-history",
history_entries.len(),
self.matched_count(),
move |history, range, _window, _cx| {
history_entries[range]
.iter()
.enumerate()
.map(|(index, entry)| {
h_flex().w_full().pb_1().child(match entry {
HistoryEntry::Thread(thread) => PastThread::new(
thread.clone(),
history.assistant_panel.clone(),
selected_index == index,
)
.into_any_element(),
HistoryEntry::Context(context) => PastContext::new(
context.clone(),
history.assistant_panel.clone(),
selected_index == index,
)
.into_any_element(),
})
let range_start = range.start;
let assistant_panel = history.assistant_panel.clone();
let render_item = |index: usize,
entry: &HistoryEntry,
highlight_positions: Vec<usize>|
-> Div {
h_flex().w_full().pb_1().child(match entry {
HistoryEntry::Thread(thread) => PastThread::new(
thread.clone(),
assistant_panel.clone(),
selected_index == index + range_start,
highlight_positions,
)
.into_any_element(),
HistoryEntry::Context(context) => PastContext::new(
context.clone(),
assistant_panel.clone(),
selected_index == index + range_start,
highlight_positions,
)
.into_any_element(),
})
.collect()
};
if history.has_search_query() {
history.matches[range]
.iter()
.enumerate()
.filter_map(|(index, m)| {
history.all_entries.get(m.candidate_id).map(|entry| {
render_item(index, entry, m.positions.clone())
})
})
.collect()
} else {
history.all_entries[range]
.iter()
.enumerate()
.map(|(index, entry)| render_item(index, entry, vec![]))
.collect()
}
},
)
.track_scroll(self.scroll_handle.clone())
@@ -224,6 +385,7 @@ pub struct PastThread {
thread: SerializedThreadMetadata,
assistant_panel: WeakEntity<AssistantPanel>,
selected: bool,
highlight_positions: Vec<usize>,
}
impl PastThread {
@@ -231,11 +393,13 @@ impl PastThread {
thread: SerializedThreadMetadata,
assistant_panel: WeakEntity<AssistantPanel>,
selected: bool,
highlight_positions: Vec<usize>,
) -> Self {
Self {
thread,
assistant_panel,
selected,
highlight_positions,
}
}
}
@@ -258,9 +422,11 @@ impl RenderOnce for PastThread {
.toggle_state(self.selected)
.spacing(ListItemSpacing::Sparse)
.start_slot(
div()
.max_w_4_5()
.child(Label::new(summary).size(LabelSize::Small).truncate()),
div().max_w_4_5().child(
HighlightedLabel::new(summary, self.highlight_positions)
.size(LabelSize::Small)
.truncate(),
),
)
.end_slot(
h_flex()
@@ -285,14 +451,21 @@ impl RenderOnce for PastThread {
IconButton::new("delete", IconName::TrashAlt)
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.tooltip(Tooltip::text("Delete Thread"))
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Thread",
&RemoveSelectedThread,
window,
cx,
)
})
.on_click({
let assistant_panel = self.assistant_panel.clone();
let id = self.thread.id.clone();
move |_event, _window, cx| {
assistant_panel
.update(cx, |this, cx| {
this.delete_thread(&id, cx);
this.delete_thread(&id, cx).detach_and_log_err(cx);
})
.ok();
}
@@ -318,6 +491,7 @@ pub struct PastContext {
context: SavedContextMetadata,
assistant_panel: WeakEntity<AssistantPanel>,
selected: bool,
highlight_positions: Vec<usize>,
}
impl PastContext {
@@ -325,11 +499,13 @@ impl PastContext {
context: SavedContextMetadata,
assistant_panel: WeakEntity<AssistantPanel>,
selected: bool,
highlight_positions: Vec<usize>,
) -> Self {
Self {
context,
assistant_panel,
selected,
highlight_positions,
}
}
}
@@ -337,7 +513,6 @@ impl PastContext {
impl RenderOnce for PastContext {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let summary = self.context.title;
let context_timestamp = time_format::format_localized_timestamp(
OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(),
OffsetDateTime::now_utc(),
@@ -354,9 +529,11 @@ impl RenderOnce for PastContext {
.toggle_state(self.selected)
.spacing(ListItemSpacing::Sparse)
.start_slot(
div()
.max_w_4_5()
.child(Label::new(summary).size(LabelSize::Small).truncate()),
div().max_w_4_5().child(
HighlightedLabel::new(summary, self.highlight_positions)
.size(LabelSize::Small)
.truncate(),
),
)
.end_slot(
h_flex()
@@ -381,14 +558,22 @@ impl RenderOnce for PastContext {
IconButton::new("delete", IconName::TrashAlt)
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.tooltip(Tooltip::text("Delete Prompt Editor"))
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Prompt Editor",
&RemoveSelectedThread,
window,
cx,
)
})
.on_click({
let assistant_panel = self.assistant_panel.clone();
let path = self.context.path.clone();
move |_event, _window, cx| {
assistant_panel
.update(cx, |this, cx| {
this.delete_context(path.clone(), cx);
this.delete_context(path.clone(), cx)
.detach_and_log_err(cx);
})
.ok();
}

View File

@@ -174,8 +174,9 @@ impl ThreadStore {
let database = database_future.await.map_err(|err| anyhow!(err))?;
database.delete_thread(id.clone()).await?;
this.update(cx, |this, _cx| {
this.threads.retain(|thread| thread.id != id)
this.update(cx, |this, cx| {
this.threads.retain(|thread| thread.id != id);
cx.notify();
})
})
}
@@ -374,6 +375,8 @@ pub struct SerializedMessage {
pub tool_uses: Vec<SerializedToolUse>,
#[serde(default)]
pub tool_results: Vec<SerializedToolResult>,
#[serde(default)]
pub context: String,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -441,6 +444,7 @@ impl LegacySerializedMessage {
segments: vec![SerializedMessageSegment::Text { text: self.text }],
tool_uses: self.tool_uses,
tool_results: self.tool_results,
context: String::new(),
}
}
}

View File

@@ -7,10 +7,11 @@ use futures::FutureExt as _;
use futures::future::Shared;
use gpui::{App, SharedString, Task};
use language_model::{
LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse,
LanguageModelToolUseId, MessageContent, Role,
LanguageModelRegistry, LanguageModelRequestMessage, LanguageModelToolResult,
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role,
};
use ui::IconName;
use util::truncate_lines_to_byte_limit;
use crate::thread::MessageId;
use crate::thread_store::SerializedMessage;
@@ -35,6 +36,18 @@ pub enum ToolUseStatus {
Error(SharedString),
}
impl ToolUseStatus {
pub fn text(&self) -> SharedString {
match self {
ToolUseStatus::NeedsConfirmation => "".into(),
ToolUseStatus::Pending => "".into(),
ToolUseStatus::Running => "".into(),
ToolUseStatus::Finished(out) => out.clone(),
ToolUseStatus::Error(out) => out.clone(),
}
}
}
pub struct ToolUseState {
tools: Arc<ToolWorkingSet>,
tool_uses_by_assistant_message: HashMap<MessageId, Vec<LanguageModelToolUse>>,
@@ -43,6 +56,8 @@ pub struct ToolUseState {
pending_tool_uses_by_id: HashMap<LanguageModelToolUseId, PendingToolUse>,
}
pub const USING_TOOL_MARKER: &str = "<using_tool>";
impl ToolUseState {
pub fn new(tools: Arc<ToolWorkingSet>) -> Self {
Self {
@@ -317,9 +332,32 @@ impl ToolUseState {
tool_use_id: LanguageModelToolUseId,
tool_name: Arc<str>,
output: Result<String>,
cx: &App,
) -> Option<PendingToolUse> {
match output {
Ok(tool_result) => {
let model_registry = LanguageModelRegistry::read_global(cx);
const BYTES_PER_TOKEN_ESTIMATE: usize = 3;
// Protect from clearly large output
let tool_output_limit = model_registry
.default_model()
.map(|model| model.model.max_token_count() * BYTES_PER_TOKEN_ESTIMATE)
.unwrap_or(usize::MAX);
let tool_result = if tool_result.len() <= tool_output_limit {
tool_result
} else {
let truncated = truncate_lines_to_byte_limit(&tool_result, tool_output_limit);
format!(
"Tool result too long. The first {} bytes:\n\n{}",
truncated.len(),
truncated
)
};
self.tool_results.insert(
tool_use_id.clone(),
LanguageModelToolResult {
@@ -357,8 +395,28 @@ impl ToolUseState {
request_message: &mut LanguageModelRequestMessage,
) {
if let Some(tool_uses) = self.tool_uses_by_assistant_message.get(&message_id) {
let mut found_tool_use = false;
for tool_use in tool_uses {
if self.tool_results.contains_key(&tool_use.id) {
if !found_tool_use {
// The API fails if a message contains a tool use without any (non-whitespace) text around it
match request_message.content.last_mut() {
Some(MessageContent::Text(txt)) => {
if txt.is_empty() {
txt.push_str(USING_TOOL_MARKER);
}
}
None | Some(_) => {
request_message
.content
.push(MessageContent::Text(USING_TOOL_MARKER.into()));
}
};
}
found_tool_use = true;
// Do not send tool uses until they are completed
request_message
.content

View File

@@ -1,5 +1,5 @@
[package]
name = "assistant_eval"
name = "agent_eval"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
@@ -9,7 +9,7 @@ license = "GPL-3.0-or-later"
workspace = true
[[bin]]
name = "assistant_eval"
name = "agent_eval"
path = "src/main.rs"
[dependencies]
@@ -27,14 +27,12 @@ fs.workspace = true
futures.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
language_models.workspace = true
node_runtime.workspace = true
project.workspace = true
prompt_store.workspace = true
regex.workspace = true
release_channel.workspace = true
reqwest_client.workspace = true
serde.workspace = true
@@ -42,4 +40,7 @@ serde_json.workspace = true
serde_json_lenient.workspace = true
settings.workspace = true
smol.workspace = true
tempfile.workspace = true
util.workspace = true
walkdir.workspace = true
workspace-hack.workspace = true

View File

@@ -0,0 +1,384 @@
use crate::git_commands::{run_git, setup_temp_repo};
use crate::headless_assistant::{HeadlessAppState, HeadlessAssistant};
use crate::{get_exercise_language, get_exercise_name};
use agent::RequestKind;
use anyhow::{Result, anyhow};
use collections::HashMap;
use gpui::{App, Task};
use language_model::{LanguageModel, TokenUsage};
use serde::{Deserialize, Serialize};
use std::{
fs,
io::Write,
path::{Path, PathBuf},
sync::Arc,
time::{Duration, SystemTime},
};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EvalResult {
pub exercise_name: String,
pub diff: String,
pub assistant_response: String,
pub elapsed_time_ms: u128,
pub timestamp: u128,
// Token usage fields
pub input_tokens: usize,
pub output_tokens: usize,
pub total_tokens: usize,
pub tool_use_counts: usize,
}
pub struct EvalOutput {
pub diff: String,
pub last_message: String,
pub elapsed_time: Duration,
pub assistant_response_count: usize,
pub tool_use_counts: HashMap<Arc<str>, u32>,
pub token_usage: TokenUsage,
}
#[derive(Deserialize)]
pub struct EvalSetup {
pub url: String,
pub base_sha: String,
}
pub struct Eval {
pub repo_path: PathBuf,
pub eval_setup: EvalSetup,
pub user_prompt: String,
}
impl Eval {
// Keep this method for potential future use, but mark it as intentionally unused
#[allow(dead_code)]
pub async fn load(_name: String, path: PathBuf, repos_dir: &Path) -> Result<Self> {
let prompt_path = path.join("prompt.txt");
let user_prompt = smol::unblock(|| std::fs::read_to_string(prompt_path)).await?;
let setup_path = path.join("setup.json");
let setup_contents = smol::unblock(|| std::fs::read_to_string(setup_path)).await?;
let eval_setup = serde_json_lenient::from_str_lenient::<EvalSetup>(&setup_contents)?;
// Move this internal function inside the load method since it's only used here
fn repo_dir_name(url: &str) -> String {
url.trim_start_matches("https://")
.replace(|c: char| !c.is_alphanumeric(), "_")
}
let repo_path = repos_dir.join(repo_dir_name(&eval_setup.url));
Ok(Eval {
repo_path,
eval_setup,
user_prompt,
})
}
pub fn run(
self,
app_state: Arc<HeadlessAppState>,
model: Arc<dyn LanguageModel>,
cx: &mut App,
) -> Task<Result<EvalOutput>> {
cx.spawn(async move |cx| {
run_git(&self.repo_path, &["checkout", &self.eval_setup.base_sha]).await?;
let (assistant, done_rx) =
cx.update(|cx| HeadlessAssistant::new(app_state.clone(), cx))??;
let _worktree = assistant
.update(cx, |assistant, cx| {
assistant.project.update(cx, |project, cx| {
project.create_worktree(&self.repo_path, true, cx)
})
})?
.await?;
let start_time = std::time::SystemTime::now();
let (system_prompt_context, load_error) = cx
.update(|cx| {
assistant
.read(cx)
.thread
.read(cx)
.load_system_prompt_context(cx)
})?
.await;
if let Some(load_error) = load_error {
return Err(anyhow!("{:?}", load_error));
};
assistant.update(cx, |assistant, cx| {
assistant.thread.update(cx, |thread, cx| {
let context = vec![];
thread.insert_user_message(self.user_prompt.clone(), context, None, cx);
thread.set_system_prompt_context(system_prompt_context);
thread.send_to_model(model, RequestKind::Chat, cx);
});
})?;
done_rx.recv().await??;
// Add this section to check untracked files
println!("Checking for untracked files:");
let untracked = run_git(
&self.repo_path,
&["ls-files", "--others", "--exclude-standard"],
)
.await?;
if untracked.is_empty() {
println!("No untracked files found");
} else {
// Add all files to git so they appear in the diff
println!("Adding untracked files to git");
run_git(&self.repo_path, &["add", "."]).await?;
}
// get git status
let _status = run_git(&self.repo_path, &["status", "--short"]).await?;
let elapsed_time = start_time.elapsed()?;
// Get diff of staged changes (the files we just added)
let staged_diff = run_git(&self.repo_path, &["diff", "--staged"]).await?;
// Get diff of unstaged changes
let unstaged_diff = run_git(&self.repo_path, &["diff"]).await?;
// Combine both diffs
let diff = if unstaged_diff.is_empty() {
staged_diff
} else if staged_diff.is_empty() {
unstaged_diff
} else {
format!(
"# Staged changes\n{}\n\n# Unstaged changes\n{}",
staged_diff, unstaged_diff
)
};
assistant.update(cx, |assistant, cx| {
let thread = assistant.thread.read(cx);
let last_message = thread.messages().last().unwrap();
if last_message.role != language_model::Role::Assistant {
return Err(anyhow!("Last message is not from assistant"));
}
let assistant_response_count = thread
.messages()
.filter(|message| message.role == language_model::Role::Assistant)
.count();
Ok(EvalOutput {
diff,
last_message: last_message.to_string(),
elapsed_time,
assistant_response_count,
tool_use_counts: assistant.tool_use_counts.clone(),
token_usage: thread.cumulative_token_usage(),
})
})?
})
}
}
impl EvalOutput {
// Keep this method for potential future use, but mark it as intentionally unused
#[allow(dead_code)]
pub fn save_to_directory(&self, output_dir: &Path, eval_output_value: String) -> Result<()> {
// Create the output directory if it doesn't exist
fs::create_dir_all(&output_dir)?;
// Save the diff to a file
let diff_path = output_dir.join("diff.patch");
let mut diff_file = fs::File::create(&diff_path)?;
diff_file.write_all(self.diff.as_bytes())?;
// Save the last message to a file
let message_path = output_dir.join("assistant_response.txt");
let mut message_file = fs::File::create(&message_path)?;
message_file.write_all(self.last_message.as_bytes())?;
// Current metrics for this run
let current_metrics = serde_json::json!({
"elapsed_time_ms": self.elapsed_time.as_millis(),
"assistant_response_count": self.assistant_response_count,
"tool_use_counts": self.tool_use_counts,
"token_usage": self.token_usage,
"eval_output_value": eval_output_value,
});
// Get current timestamp in milliseconds
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis()
.to_string();
// Path to metrics file
let metrics_path = output_dir.join("metrics.json");
// Load existing metrics if the file exists, or create a new object
let mut historical_metrics = if metrics_path.exists() {
let metrics_content = fs::read_to_string(&metrics_path)?;
serde_json::from_str::<serde_json::Value>(&metrics_content)
.unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
// Add new run with timestamp as key
if let serde_json::Value::Object(ref mut map) = historical_metrics {
map.insert(timestamp, current_metrics);
}
// Write updated metrics back to file
let metrics_json = serde_json::to_string_pretty(&historical_metrics)?;
let mut metrics_file = fs::File::create(&metrics_path)?;
metrics_file.write_all(metrics_json.as_bytes())?;
Ok(())
}
}
pub async fn read_instructions(exercise_path: &Path) -> Result<String> {
let instructions_path = exercise_path.join(".docs").join("instructions.md");
println!("Reading instructions from: {}", instructions_path.display());
let instructions = smol::unblock(move || std::fs::read_to_string(&instructions_path)).await?;
Ok(instructions)
}
pub async fn save_eval_results(exercise_path: &Path, results: Vec<EvalResult>) -> Result<()> {
let eval_dir = exercise_path.join("evaluation");
fs::create_dir_all(&eval_dir)?;
let eval_file = eval_dir.join("evals.json");
println!("Saving evaluation results to: {}", eval_file.display());
println!(
"Results to save: {} evaluations for exercise path: {}",
results.len(),
exercise_path.display()
);
// Check file existence before reading/writing
if eval_file.exists() {
println!("Existing evals.json file found, will update it");
} else {
println!("No existing evals.json file found, will create new one");
}
// Structure to organize evaluations by test name and timestamp
let mut eval_data: serde_json::Value = if eval_file.exists() {
let content = fs::read_to_string(&eval_file)?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
// Get current timestamp for this batch of results
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_millis()
.to_string();
// Group the new results by test name (exercise name)
for result in results {
let exercise_name = &result.exercise_name;
println!("Adding result: exercise={}", exercise_name);
// Ensure the exercise entry exists
if eval_data.get(exercise_name).is_none() {
eval_data[exercise_name] = serde_json::json!({});
}
// Ensure the timestamp entry exists as an object
if eval_data[exercise_name].get(&timestamp).is_none() {
eval_data[exercise_name][&timestamp] = serde_json::json!({});
}
// Add this result under the timestamp with template name as key
eval_data[exercise_name][&timestamp] = serde_json::to_value(&result)?;
}
// Write back to file with pretty formatting
let json_content = serde_json::to_string_pretty(&eval_data)?;
match fs::write(&eval_file, json_content) {
Ok(_) => println!("✓ Successfully saved results to {}", eval_file.display()),
Err(e) => println!("✗ Failed to write results file: {}", e),
}
Ok(())
}
pub async fn run_exercise_eval(
exercise_path: PathBuf,
model: Arc<dyn LanguageModel>,
app_state: Arc<HeadlessAppState>,
base_sha: String,
_framework_path: PathBuf,
cx: gpui::AsyncApp,
) -> Result<EvalResult> {
let exercise_name = get_exercise_name(&exercise_path);
let language = get_exercise_language(&exercise_path)?;
let mut instructions = read_instructions(&exercise_path).await?;
instructions.push_str(&format!(
"\n\nWhen writing the code for this prompt, use {} to achieve the goal.",
language
));
println!("Running evaluation for exercise: {}", exercise_name);
// Create temporary directory with exercise files
let temp_dir = setup_temp_repo(&exercise_path, &base_sha).await?;
let temp_path = temp_dir.path().to_path_buf();
let local_commit_sha = run_git(&temp_path, &["rev-parse", "HEAD"]).await?;
let start_time = SystemTime::now();
// Create a basic eval struct to work with the existing system
let eval = Eval {
repo_path: temp_path.clone(),
eval_setup: EvalSetup {
url: format!("file://{}", temp_path.display()),
base_sha: local_commit_sha, // Use the local commit SHA instead of the framework base SHA
},
user_prompt: instructions.clone(),
};
// Run the evaluation
let eval_output = cx
.update(|cx| eval.run(app_state.clone(), model.clone(), cx))?
.await?;
// Get diff from git
let diff = eval_output.diff.clone();
let elapsed_time = start_time.elapsed()?;
// Calculate total tokens as the sum of input and output tokens
let input_tokens = eval_output.token_usage.input_tokens;
let output_tokens = eval_output.token_usage.output_tokens;
let tool_use_counts = eval_output.tool_use_counts.values().sum::<u32>();
let total_tokens = input_tokens + output_tokens;
// Save results to evaluation directory
let result = EvalResult {
exercise_name: exercise_name.clone(),
diff,
assistant_response: eval_output.last_message.clone(),
elapsed_time_ms: elapsed_time.as_millis(),
timestamp: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_millis(),
// Convert u32 token counts to usize
input_tokens: input_tokens.try_into().unwrap(),
output_tokens: output_tokens.try_into().unwrap(),
total_tokens: total_tokens.try_into().unwrap(),
tool_use_counts: tool_use_counts.try_into().unwrap(),
};
Ok(result)
}

View File

@@ -0,0 +1,149 @@
use anyhow::{Result, anyhow};
use std::{
fs,
path::{Path, PathBuf},
};
pub fn get_exercise_name(exercise_path: &Path) -> String {
exercise_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
}
pub fn get_exercise_language(exercise_path: &Path) -> Result<String> {
// Extract the language from path (data/python/exercises/... => python)
let parts: Vec<_> = exercise_path.components().collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 && part.as_os_str() == "eval_code" {
if i + 1 < parts.len() {
let language = parts[i + 1].as_os_str().to_string_lossy().to_string();
return Ok(language);
}
}
}
Err(anyhow!(
"Could not determine language from path: {:?}",
exercise_path
))
}
pub fn find_exercises(
framework_path: &Path,
languages: &[&str],
max_per_language: Option<usize>,
) -> Result<Vec<PathBuf>> {
let mut all_exercises = Vec::new();
println!("Searching for exercises in languages: {:?}", languages);
for language in languages {
let language_dir = framework_path
.join("eval_code")
.join(language)
.join("exercises")
.join("practice");
println!("Checking language directory: {:?}", language_dir);
if !language_dir.exists() {
println!("Warning: Language directory not found: {:?}", language_dir);
continue;
}
let mut exercises = Vec::new();
match fs::read_dir(&language_dir) {
Ok(entries) => {
for entry_result in entries {
match entry_result {
Ok(entry) => {
let path = entry.path();
if path.is_dir() {
// Special handling for "internal" directory
if *language == "internal" {
// Check for repo_info.json to validate it's an internal exercise
let repo_info_path = path.join(".meta").join("repo_info.json");
let instructions_path =
path.join(".docs").join("instructions.md");
if repo_info_path.exists() && instructions_path.exists() {
exercises.push(path);
}
} else {
// Map the language to the file extension - original code
let language_extension = match *language {
"python" => "py",
"go" => "go",
"rust" => "rs",
"typescript" => "ts",
"javascript" => "js",
"ruby" => "rb",
"php" => "php",
"bash" => "sh",
"multi" => "diff",
_ => continue, // Skip unsupported languages
};
// Check if this is a valid exercise with instructions and example
let instructions_path =
path.join(".docs").join("instructions.md");
let has_instructions = instructions_path.exists();
let example_path = path
.join(".meta")
.join(format!("example.{}", language_extension));
let has_example = example_path.exists();
if has_instructions && has_example {
exercises.push(path);
}
}
}
}
Err(err) => println!("Error reading directory entry: {}", err),
}
}
}
Err(err) => println!(
"Error reading directory {}: {}",
language_dir.display(),
err
),
}
// Sort exercises by name for consistent selection
exercises.sort_by(|a, b| {
let a_name = a.file_name().unwrap_or_default().to_string_lossy();
let b_name = b.file_name().unwrap_or_default().to_string_lossy();
a_name.cmp(&b_name)
});
// Apply the limit if specified
if let Some(limit) = max_per_language {
if exercises.len() > limit {
println!(
"Limiting {} exercises to {} for language {}",
exercises.len(),
limit,
language
);
exercises.truncate(limit);
}
}
println!(
"Found {} exercises for language {}: {:?}",
exercises.len(),
language,
exercises
.iter()
.map(|p| p.file_name().unwrap_or_default().to_string_lossy())
.collect::<Vec<_>>()
);
all_exercises.extend(exercises);
}
Ok(all_exercises)
}

View File

@@ -0,0 +1,125 @@
use anyhow::{Result, anyhow};
use serde::Deserialize;
use std::{fs, path::Path};
use tempfile::TempDir;
use util::command::new_smol_command;
use walkdir::WalkDir;
#[derive(Debug, Deserialize)]
pub struct SetupConfig {
#[serde(rename = "base.sha")]
pub base_sha: String,
}
#[derive(Debug, Deserialize)]
pub struct RepoInfo {
pub remote_url: String,
pub head_sha: String,
}
pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
let output = new_smol_command("git")
.current_dir(repo_path)
.args(args)
.output()
.await?;
if output.status.success() {
Ok(String::from_utf8(output.stdout)?.trim().to_string())
} else {
Err(anyhow!(
"Git command failed: {} with status: {}",
args.join(" "),
output.status
))
}
}
pub async fn read_base_sha(framework_path: &Path) -> Result<String> {
let setup_path = framework_path.join("setup.json");
let setup_content = smol::unblock(move || std::fs::read_to_string(&setup_path)).await?;
let setup_config: SetupConfig = serde_json_lenient::from_str_lenient(&setup_content)?;
Ok(setup_config.base_sha)
}
pub async fn read_repo_info(exercise_path: &Path) -> Result<RepoInfo> {
let repo_info_path = exercise_path.join(".meta").join("repo_info.json");
println!("Reading repo info from: {}", repo_info_path.display());
let repo_info_content = smol::unblock(move || std::fs::read_to_string(&repo_info_path)).await?;
let repo_info: RepoInfo = serde_json_lenient::from_str_lenient(&repo_info_content)?;
// Remove any quotes from the strings
let remote_url = repo_info.remote_url.trim_matches('"').to_string();
let head_sha = repo_info.head_sha.trim_matches('"').to_string();
Ok(RepoInfo {
remote_url,
head_sha,
})
}
pub async fn setup_temp_repo(exercise_path: &Path, _base_sha: &str) -> Result<TempDir> {
let temp_dir = TempDir::new()?;
// Check if this is an internal exercise by looking for repo_info.json
let repo_info_path = exercise_path.join(".meta").join("repo_info.json");
if repo_info_path.exists() {
// This is an internal exercise, handle it differently
let repo_info = read_repo_info(exercise_path).await?;
// Clone the repository to the temp directory
let url = repo_info.remote_url;
let clone_path = temp_dir.path();
println!(
"Cloning repository from {} to {}",
url,
clone_path.display()
);
run_git(
&std::env::current_dir()?,
&["clone", &url, &clone_path.to_string_lossy()],
)
.await?;
// Checkout the specified commit
println!("Checking out commit: {}", repo_info.head_sha);
run_git(temp_dir.path(), &["checkout", &repo_info.head_sha]).await?;
println!("Successfully set up internal repository");
} else {
// Original code for regular exercises
// Copy the exercise files to the temp directory, excluding .docs and .meta
for entry in WalkDir::new(exercise_path).min_depth(0).max_depth(10) {
let entry = entry?;
let source_path = entry.path();
// Skip .docs and .meta directories completely
if source_path.starts_with(exercise_path.join(".docs"))
|| source_path.starts_with(exercise_path.join(".meta"))
{
continue;
}
if source_path.is_file() {
let relative_path = source_path.strip_prefix(exercise_path)?;
let dest_path = temp_dir.path().join(relative_path);
// Make sure parent directories exist
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source_path, dest_path)?;
}
}
// Initialize git repo in the temp directory
run_git(temp_dir.path(), &["init"]).await?;
run_git(temp_dir.path(), &["add", "."]).await?;
run_git(temp_dir.path(), &["commit", "-m", "Initial commit"]).await?;
println!("Created temp repo without .docs and .meta directories");
}
Ok(temp_dir)
}

View File

@@ -4,12 +4,10 @@ use assistant_tool::ToolWorkingSet;
use client::{Client, UserStore};
use collections::HashMap;
use dap::DapRegistry;
use futures::StreamExt;
use gpui::{App, AsyncApp, Entity, SemanticVersion, Subscription, Task, prelude::*};
use gpui::{App, Entity, SemanticVersion, Subscription, Task, prelude::*};
use language::LanguageRegistry;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelProviderId, LanguageModelRegistry,
LanguageModelRequest,
};
use node_runtime::NodeRuntime;
use project::{Project, RealFs};
@@ -102,6 +100,40 @@ impl HeadlessAssistant {
thread.use_pending_tools(cx);
});
}
ThreadEvent::ToolConfirmationNeeded => {
// Automatically approve all tools that need confirmation in headless mode
println!("Tool confirmation needed - automatically approving in headless mode");
// Get the tools needing confirmation
let tools_needing_confirmation: Vec<_> = thread
.read(cx)
.tools_needing_confirmation()
.cloned()
.collect();
// Run each tool that needs confirmation
for tool_use in tools_needing_confirmation {
if let Some(tool) = thread.read(cx).tools().tool(&tool_use.name, cx) {
thread.update(cx, |thread, cx| {
println!("Auto-approving tool: {}", tool_use.name);
// Create a request to send to the tool
let request = thread.to_completion_request(RequestKind::Chat, cx);
let messages = Arc::new(request.messages);
// Run the tool
thread.run_tool(
tool_use.id.clone(),
tool_use.ui_text.clone(),
tool_use.input.clone(),
&messages,
tool,
cx,
);
});
}
}
}
ThreadEvent::ToolFinished {
tool_use_id,
pending_tool_use,
@@ -122,11 +154,15 @@ impl HeadlessAssistant {
}
if thread.read(cx).all_tools_finished() {
let model_registry = LanguageModelRegistry::read_global(cx);
if let Some(model) = model_registry.active_model() {
if let Some(model) = model_registry.default_model() {
thread.update(cx, |thread, cx| {
thread.attach_tool_results(vec![], cx);
thread.send_to_model(model, RequestKind::Chat, cx);
thread.attach_tool_results(cx);
thread.send_to_model(model.model, RequestKind::Chat, cx);
});
} else {
println!(
"Warning: No active language model available to continue conversation"
);
}
}
}
@@ -208,34 +244,3 @@ pub fn authenticate_model_provider(
let model_provider = model_registry.provider(&provider_id).unwrap();
model_provider.authenticate(cx)
}
pub async fn send_language_model_request(
model: Arc<dyn LanguageModel>,
request: LanguageModelRequest,
cx: &mut AsyncApp,
) -> anyhow::Result<String> {
match model.stream_completion_text(request, &cx).await {
Ok(mut stream) => {
let mut full_response = String::new();
// Process the response stream
while let Some(chunk_result) = stream.stream.next().await {
match chunk_result {
Ok(chunk_str) => {
full_response.push_str(&chunk_str);
}
Err(err) => {
return Err(anyhow!(
"Error receiving response from language model: {err}"
));
}
}
}
Ok(full_response)
}
Err(err) => Err(anyhow!(
"Failed to get response from language model. Error was: {err}"
)),
}
}

View File

@@ -0,0 +1,205 @@
mod eval;
mod get_exercise;
mod git_commands;
mod headless_assistant;
use clap::Parser;
use eval::{run_exercise_eval, save_eval_results};
use futures::stream::{self, StreamExt};
use get_exercise::{find_exercises, get_exercise_language, get_exercise_name};
use git_commands::read_base_sha;
use gpui::Application;
use headless_assistant::{authenticate_model_provider, find_model};
use language_model::LanguageModelRegistry;
use reqwest_client::ReqwestClient;
use std::{path::PathBuf, sync::Arc};
#[derive(Parser, Debug)]
#[command(
name = "agent_eval",
disable_version_flag = true,
before_help = "Tool eval runner"
)]
struct Args {
/// Match the names of evals to run.
#[arg(long)]
exercise_names: Vec<String>,
/// Runs all exercises, causes the exercise_names to be ignored.
#[arg(long)]
all: bool,
/// Supported language types to evaluate (default: internal).
/// Internal is data generated from the agent panel
#[arg(long, default_value = "internal")]
languages: String,
/// Name of the model (default: "claude-3-7-sonnet-latest")
#[arg(long, default_value = "claude-3-7-sonnet-latest")]
model_name: String,
/// Name of the editor model (default: value of `--model_name`).
#[arg(long)]
editor_model_name: Option<String>,
/// Number of evaluations to run concurrently (default: 3)
#[arg(short, long, default_value = "5")]
concurrency: usize,
/// Maximum number of exercises to evaluate per language
#[arg(long)]
max_exercises_per_language: Option<usize>,
}
fn main() {
env_logger::init();
let args = Args::parse();
let http_client = Arc::new(ReqwestClient::new());
let app = Application::headless().with_http_client(http_client.clone());
// Path to the zed-ace-framework repo
let framework_path = PathBuf::from("../zed-ace-framework")
.canonicalize()
.unwrap();
// Fix the 'languages' lifetime issue by creating owned Strings instead of slices
let languages: Vec<String> = args.languages.split(',').map(|s| s.to_string()).collect();
println!("Using zed-ace-framework at: {:?}", framework_path);
println!("Evaluating languages: {:?}", languages);
app.run(move |cx| {
let app_state = headless_assistant::init(cx);
let model = find_model(&args.model_name, cx).unwrap();
let editor_model = if let Some(model_name) = &args.editor_model_name {
find_model(model_name, cx).unwrap()
} else {
model.clone()
};
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_default_model(Some(model.clone()), cx);
});
let model_provider_id = model.provider_id();
let editor_model_provider_id = editor_model.provider_id();
let framework_path_clone = framework_path.clone();
let languages_clone = languages.clone();
let exercise_names = args.exercise_names.clone();
let all_flag = args.all;
cx.spawn(async move |cx| {
// Authenticate all model providers first
cx.update(|cx| authenticate_model_provider(model_provider_id.clone(), cx))
.unwrap()
.await
.unwrap();
cx.update(|cx| authenticate_model_provider(editor_model_provider_id.clone(), cx))
.unwrap()
.await
.unwrap();
println!("framework path: {}", framework_path_clone.display());
let base_sha = read_base_sha(&framework_path_clone).await.unwrap();
println!("base sha: {}", base_sha);
let all_exercises = find_exercises(
&framework_path_clone,
&languages_clone
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>(),
args.max_exercises_per_language,
)
.unwrap();
println!("Found {} exercises total", all_exercises.len());
// Filter exercises if specific ones were requested
let exercises_to_run = if !exercise_names.is_empty() {
// If exercise names are specified, filter by them regardless of --all flag
all_exercises
.into_iter()
.filter(|path| {
let name = get_exercise_name(path);
exercise_names.iter().any(|filter| name.contains(filter))
})
.collect()
} else if all_flag {
// Only use all_flag if no exercise names are specified
all_exercises
} else {
// Default behavior (no filters)
all_exercises
};
println!("Will run {} exercises", exercises_to_run.len());
// Create exercise eval tasks - each exercise is a single task that will run templates sequentially
let exercise_tasks: Vec<_> = exercises_to_run
.into_iter()
.map(|exercise_path| {
let exercise_name = get_exercise_name(&exercise_path);
let model_clone = model.clone();
let app_state_clone = app_state.clone();
let base_sha_clone = base_sha.clone();
let framework_path_clone = framework_path_clone.clone();
let cx_clone = cx.clone();
async move {
println!("Processing exercise: {}", exercise_name);
let mut exercise_results = Vec::new();
match run_exercise_eval(
exercise_path.clone(),
model_clone.clone(),
app_state_clone.clone(),
base_sha_clone.clone(),
framework_path_clone.clone(),
cx_clone.clone(),
)
.await
{
Ok(result) => {
println!("Completed {}", exercise_name);
exercise_results.push(result);
}
Err(err) => {
println!("Error running {}: {}", exercise_name, err);
}
}
// Save results for this exercise
if !exercise_results.is_empty() {
if let Err(err) =
save_eval_results(&exercise_path, exercise_results.clone()).await
{
println!("Error saving results for {}: {}", exercise_name, err);
} else {
println!("Saved results for {}", exercise_name);
}
}
exercise_results
}
})
.collect();
println!(
"Running {} exercises with concurrency: {}",
exercise_tasks.len(),
args.concurrency
);
// Run exercises concurrently, with each exercise running its templates sequentially
let all_results = stream::iter(exercise_tasks)
.buffer_unordered(args.concurrency)
.flat_map(stream::iter)
.collect::<Vec<_>>()
.await;
println!("Completed {} evaluation runs", all_results.len());
cx.update(|cx| cx.quit()).unwrap();
})
.detach();
});
println!("Done running evals");
}

View File

@@ -26,3 +26,4 @@ serde_json.workspace = true
strum.workspace = true
thiserror.workspace = true
util.workspace = true
workspace-hack.workspace = true

View File

@@ -1,10 +1,10 @@
mod supported_countries;
use std::{pin::Pin, str::FromStr};
use std::str::FromStr;
use anyhow::{Context as _, Result, anyhow};
use chrono::{DateTime, Utc};
use futures::{AsyncBufReadExt, AsyncReadExt, Stream, StreamExt, io::BufReader, stream::BoxStream};
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use http_client::http::{HeaderMap, HeaderValue};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use serde::{Deserialize, Serialize};
@@ -220,16 +220,23 @@ impl Model {
.map(|header| header.to_string())
.collect::<Vec<_>>();
if let Self::Custom {
extra_beta_headers, ..
} = self
{
headers.extend(
extra_beta_headers
.iter()
.filter(|header| !header.trim().is_empty())
.cloned(),
);
match self {
Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => {
// Try beta token-efficient tool use (supported in Claude 3.7 Sonnet only)
// https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use
headers.push("token-efficient-tools-2025-02-19".to_string());
}
Self::Custom {
extra_beta_headers, ..
} => {
headers.extend(
extra_beta_headers
.iter()
.filter(|header| !header.trim().is_empty())
.cloned(),
);
}
_ => {}
}
headers.join(",")
@@ -314,38 +321,54 @@ pub async fn stream_completion(
.map(|output| output.0)
}
/// An individual rate limit.
#[derive(Debug)]
pub struct RateLimit {
pub limit: usize,
pub remaining: usize,
pub reset: DateTime<Utc>,
}
impl RateLimit {
fn from_headers(resource: &str, headers: &HeaderMap<HeaderValue>) -> Result<Self> {
let limit =
get_header(&format!("anthropic-ratelimit-{resource}-limit"), headers)?.parse()?;
let remaining = get_header(
&format!("anthropic-ratelimit-{resource}-remaining"),
headers,
)?
.parse()?;
let reset = DateTime::parse_from_rfc3339(get_header(
&format!("anthropic-ratelimit-{resource}-reset"),
headers,
)?)?
.to_utc();
Ok(Self {
limit,
remaining,
reset,
})
}
}
/// <https://docs.anthropic.com/en/api/rate-limits#response-headers>
#[derive(Debug)]
pub struct RateLimitInfo {
pub requests_limit: usize,
pub requests_remaining: usize,
pub requests_reset: DateTime<Utc>,
pub tokens_limit: usize,
pub tokens_remaining: usize,
pub tokens_reset: DateTime<Utc>,
pub requests: Option<RateLimit>,
pub tokens: Option<RateLimit>,
pub input_tokens: Option<RateLimit>,
pub output_tokens: Option<RateLimit>,
}
impl RateLimitInfo {
fn from_headers(headers: &HeaderMap<HeaderValue>) -> Result<Self> {
let tokens_limit = get_header("anthropic-ratelimit-tokens-limit", headers)?.parse()?;
let requests_limit = get_header("anthropic-ratelimit-requests-limit", headers)?.parse()?;
let tokens_remaining =
get_header("anthropic-ratelimit-tokens-remaining", headers)?.parse()?;
let requests_remaining =
get_header("anthropic-ratelimit-requests-remaining", headers)?.parse()?;
let requests_reset = get_header("anthropic-ratelimit-requests-reset", headers)?;
let tokens_reset = get_header("anthropic-ratelimit-tokens-reset", headers)?;
let requests_reset = DateTime::parse_from_rfc3339(requests_reset)?.to_utc();
let tokens_reset = DateTime::parse_from_rfc3339(tokens_reset)?.to_utc();
Ok(Self {
requests_limit,
tokens_limit,
requests_remaining,
tokens_remaining,
requests_reset,
tokens_reset,
})
fn from_headers(headers: &HeaderMap<HeaderValue>) -> Self {
Self {
requests: RateLimit::from_headers("requests", headers).log_err(),
tokens: RateLimit::from_headers("tokens", headers).log_err(),
input_tokens: RateLimit::from_headers("input-tokens", headers).log_err(),
output_tokens: RateLimit::from_headers("output-tokens", headers).log_err(),
}
}
}
@@ -411,7 +434,7 @@ pub async fn stream_completion_with_rate_limit_info(
}
})
.boxed();
Ok((stream, rate_limits.log_err()))
Ok((stream, Some(rate_limits)))
} else {
let mut body = Vec::new();
response
@@ -437,50 +460,6 @@ pub async fn stream_completion_with_rate_limit_info(
}
}
pub async fn extract_tool_args_from_events(
tool_name: String,
mut events: Pin<Box<dyn Send + Stream<Item = Result<Event>>>>,
) -> Result<impl Send + Stream<Item = Result<String>>> {
let mut tool_use_index = None;
while let Some(event) = events.next().await {
if let Event::ContentBlockStart {
index,
content_block: ResponseContent::ToolUse { name, .. },
} = event?
{
if name == tool_name {
tool_use_index = Some(index);
break;
}
}
}
let Some(tool_use_index) = tool_use_index else {
return Err(anyhow!("tool not used"));
};
Ok(events.filter_map(move |event| {
let result = match event {
Err(error) => Some(Err(error)),
Ok(Event::ContentBlockDelta { index, delta }) => match delta {
ContentDelta::TextDelta { .. } => None,
ContentDelta::ThinkingDelta { .. } => None,
ContentDelta::SignatureDelta { .. } => None,
ContentDelta::InputJsonDelta { partial_json } => {
if index == tool_use_index {
Some(Ok(partial_json))
} else {
None
}
}
},
_ => None,
};
async move { result }
}))
}
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
#[serde(rename_all = "lowercase")]
pub enum CacheControlType {

View File

@@ -19,3 +19,4 @@ smol.workspace = true
tempfile.workspace = true
util.workspace = true
which.workspace = true
workspace-hack.workspace = true

View File

@@ -15,3 +15,4 @@ workspace = true
anyhow.workspace = true
gpui.workspace = true
rust-embed.workspace = true
workspace-hack.workspace = true

View File

@@ -69,6 +69,7 @@ ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
ctor.workspace = true

View File

@@ -161,12 +161,38 @@ fn init_language_model_settings(cx: &mut App) {
fn update_active_language_model_from_settings(cx: &mut App) {
let settings = AssistantSettings::get_global(cx);
// Default model - used as fallback
let active_model_provider_name =
LanguageModelProviderId::from(settings.default_model.provider.clone());
let active_model_id = LanguageModelId::from(settings.default_model.model.clone());
let editor_provider_name =
LanguageModelProviderId::from(settings.editor_model.provider.clone());
let editor_model_id = LanguageModelId::from(settings.editor_model.model.clone());
// Inline assistant model
let inline_assistant_model = settings
.inline_assistant_model
.as_ref()
.unwrap_or(&settings.default_model);
let inline_assistant_provider_name =
LanguageModelProviderId::from(inline_assistant_model.provider.clone());
let inline_assistant_model_id = LanguageModelId::from(inline_assistant_model.model.clone());
// Commit message model
let commit_message_model = settings
.commit_message_model
.as_ref()
.unwrap_or(&settings.default_model);
let commit_message_provider_name =
LanguageModelProviderId::from(commit_message_model.provider.clone());
let commit_message_model_id = LanguageModelId::from(commit_message_model.model.clone());
// Thread summary model
let thread_summary_model = settings
.thread_summary_model
.as_ref()
.unwrap_or(&settings.default_model);
let thread_summary_provider_name =
LanguageModelProviderId::from(thread_summary_model.provider.clone());
let thread_summary_model_id = LanguageModelId::from(thread_summary_model.model.clone());
let inline_alternatives = settings
.inline_alternatives
.iter()
@@ -177,9 +203,29 @@ fn update_active_language_model_from_settings(cx: &mut App) {
)
})
.collect::<Vec<_>>();
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.select_active_model(&active_model_provider_name, &active_model_id, cx);
registry.select_editor_model(&editor_provider_name, &editor_model_id, cx);
// Set the default model
registry.select_default_model(&active_model_provider_name, &active_model_id, cx);
// Set the specific models
registry.select_inline_assistant_model(
&inline_assistant_provider_name,
&inline_assistant_model_id,
cx,
);
registry.select_commit_message_model(
&commit_message_provider_name,
&commit_message_model_id,
cx,
);
registry.select_thread_summary_model(
&thread_summary_provider_name,
&thread_summary_model_id,
cx,
);
// Set the alternatives
registry.select_inline_alternative_models(inline_alternatives, cx);
});
}

View File

@@ -22,7 +22,8 @@ use gpui::{
};
use language::LanguageRegistry;
use language_model::{
AuthenticateError, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
AuthenticateError, ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry,
ZED_CLOUD_PROVIDER_ID,
};
use project::Project;
use prompt_library::{PromptLibrary, open_prompt_library};
@@ -36,11 +37,11 @@ use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*};
use util::{ResultExt, maybe};
use workspace::DraggedTab;
use workspace::{
DraggedSelection, Pane, ShowConfiguration, ToggleZoom, Workspace,
DraggedSelection, Pane, ToggleZoom, Workspace,
dock::{DockPosition, Panel, PanelEvent},
pane,
};
use zed_actions::assistant::{InlineAssist, OpenPromptLibrary, ToggleFocus};
use zed_actions::assistant::{InlineAssist, OpenPromptLibrary, ShowConfiguration, ToggleFocus};
pub fn init(cx: &mut App) {
workspace::FollowableViewRegistry::register::<ContextEditor>(cx);
@@ -53,7 +54,15 @@ pub fn init(cx: &mut App) {
.register_action(ContextEditor::insert_dragged_files)
.register_action(AssistantPanel::show_configuration)
.register_action(AssistantPanel::create_new_context)
.register_action(AssistantPanel::restart_context_servers);
.register_action(AssistantPanel::restart_context_servers)
.register_action(|workspace, _: &OpenPromptLibrary, window, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
workspace.focus_panel::<AssistantPanel>(window, cx);
panel.update(cx, |panel, cx| {
panel.deploy_prompt_library(&OpenPromptLibrary, window, cx)
});
}
});
},
)
.detach();
@@ -298,8 +307,10 @@ impl AssistantPanel {
&LanguageModelRegistry::global(cx),
window,
|this, _, event: &language_model::Event, window, cx| match event {
language_model::Event::ActiveModelChanged
| language_model::Event::EditorModelChanged => {
language_model::Event::DefaultModelChanged
| language_model::Event::InlineAssistantModelChanged
| language_model::Event::CommitMessageModelChanged
| language_model::Event::ThreadSummaryModelChanged => {
this.completion_provider_changed(window, cx);
}
language_model::Event::ProviderStateChanged => {
@@ -468,12 +479,12 @@ impl AssistantPanel {
}
fn update_zed_ai_notice_visibility(&mut self, client_status: Status, cx: &mut Context<Self>) {
let active_provider = LanguageModelRegistry::read_global(cx).active_provider();
let model = LanguageModelRegistry::read_global(cx).default_model();
// If we're signed out and don't have a provider configured, or we're signed-out AND Zed.dev is
// the provider, we want to show a nudge to sign in.
let show_zed_ai_notice = client_status.is_signed_out()
&& active_provider.map_or(true, |provider| provider.id().0 == ZED_CLOUD_PROVIDER_ID);
&& model.map_or(true, |model| model.provider.id().0 == ZED_CLOUD_PROVIDER_ID);
self.show_zed_ai_notice = show_zed_ai_notice;
cx.notify();
@@ -541,8 +552,8 @@ impl AssistantPanel {
}
let Some(new_provider_id) = LanguageModelRegistry::read_global(cx)
.active_provider()
.map(|p| p.id())
.default_model()
.map(|default| default.provider.id())
else {
return;
};
@@ -568,7 +579,9 @@ impl AssistantPanel {
return;
}
let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
let Some(ConfiguredModel { provider, .. }) =
LanguageModelRegistry::read_global(cx).default_model()
else {
return;
};
@@ -976,8 +989,8 @@ impl AssistantPanel {
|this, _, event: &ConfigurationViewEvent, window, cx| match event {
ConfigurationViewEvent::NewProviderContextEditor(provider) => {
if LanguageModelRegistry::read_global(cx)
.active_provider()
.map_or(true, |p| p.id() != provider.id())
.default_model()
.map_or(true, |default| default.provider.id() != provider.id())
{
if let Some(model) = provider.default_model(cx) {
update_settings_file::<AssistantSettings>(
@@ -1155,8 +1168,8 @@ impl AssistantPanel {
fn is_authenticated(&mut self, cx: &mut Context<Self>) -> bool {
LanguageModelRegistry::read_global(cx)
.active_provider()
.map_or(false, |provider| provider.is_authenticated(cx))
.default_model()
.map_or(false, |default| default.provider.is_authenticated(cx))
}
fn authenticate(
@@ -1164,8 +1177,8 @@ impl AssistantPanel {
cx: &mut Context<Self>,
) -> Option<Task<Result<(), AuthenticateError>>> {
LanguageModelRegistry::read_global(cx)
.active_provider()
.map_or(None, |provider| Some(provider.authenticate(cx)))
.default_model()
.map_or(None, |default| Some(default.provider.authenticate(cx)))
}
fn restart_context_servers(

View File

@@ -34,8 +34,8 @@ use gpui::{
};
use language::{Buffer, IndentKind, Point, Selection, TransactionId, line_diff};
use language_model::{
LanguageModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
LanguageModelTextStream, Role, report_assistant_event,
ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, LanguageModelTextStream, Role, report_assistant_event,
};
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
use multi_buffer::MultiBufferRow;
@@ -57,7 +57,7 @@ use std::{
time::{Duration, Instant},
};
use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use terminal_view::terminal_panel::TerminalPanel;
use text::{OffsetRangeExt, ToPoint as _};
use theme::ThemeSettings;
@@ -312,8 +312,10 @@ impl InlineAssistant {
start..end,
));
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
self.telemetry.report_assistant_event(AssistantEvent {
if let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).default_model()
{
self.telemetry.report_assistant_event(AssistantEventData {
conversation_id: None,
kind: AssistantKind::Inline,
phase: AssistantPhase::Invoked,
@@ -525,14 +527,14 @@ impl InlineAssistant {
BlockProperties {
style: BlockStyle::Sticky,
placement: BlockPlacement::Above(range.start),
height: prompt_editor_height,
height: Some(prompt_editor_height),
render: build_assist_editor_renderer(prompt_editor),
priority: 0,
},
BlockProperties {
style: BlockStyle::Sticky,
placement: BlockPlacement::Below(range.end),
height: 0,
height: None,
render: Arc::new(|cx| {
v_flex()
.h_full()
@@ -877,7 +879,9 @@ impl InlineAssistant {
let active_alternative = assist.codegen.read(cx).active_alternative().clone();
let message_id = active_alternative.read(cx).message_id.clone();
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
if let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).default_model()
{
let language_name = assist.editor.upgrade().and_then(|editor| {
let multibuffer = editor.read(cx).buffer().read(cx);
let multibuffer_snapshot = multibuffer.snapshot(cx);
@@ -888,7 +892,7 @@ impl InlineAssistant {
.map(|language| language.name())
});
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
kind: AssistantKind::Inline,
message_id,
@@ -1297,7 +1301,7 @@ impl InlineAssistant {
deleted_lines_editor.update(cx, |editor, cx| editor.max_point(cx).row().0 + 1);
new_blocks.push(BlockProperties {
placement: BlockPlacement::Above(new_row),
height,
height: Some(height),
style: BlockStyle::Flex,
render: Arc::new(move |cx| {
div()
@@ -1629,8 +1633,8 @@ impl Render for PromptEditor {
format!(
"Using {}",
LanguageModelRegistry::read_global(cx)
.active_model()
.map(|model| model.name().0)
.default_model()
.map(|default| default.model.name().0)
.unwrap_or_else(|| "No model selected".into()),
),
None,
@@ -2077,7 +2081,7 @@ impl PromptEditor {
let disabled = matches!(codegen.status(cx), CodegenStatus::Idle);
let model_registry = LanguageModelRegistry::read_global(cx);
let default_model = model_registry.active_model();
let default_model = model_registry.default_model().map(|default| default.model);
let alternative_models = model_registry.inline_alternative_models();
let get_model_name = |index: usize| -> String {
@@ -2183,7 +2187,9 @@ impl PromptEditor {
}
fn render_token_count(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let model = LanguageModelRegistry::read_global(cx).active_model()?;
let model = LanguageModelRegistry::read_global(cx)
.default_model()?
.model;
let token_counts = self.token_counts?;
let max_token_count = model.max_token_count();
@@ -2638,8 +2644,9 @@ impl Codegen {
}
let primary_model = LanguageModelRegistry::read_global(cx)
.active_model()
.context("no active model")?;
.default_model()
.context("no active model")?
.model;
for (model, alternative) in iter::once(primary_model)
.chain(alternative_models)
@@ -2863,7 +2870,9 @@ impl CodegenAlternative {
assistant_panel_context: Option<LanguageModelRequest>,
cx: &App,
) -> BoxFuture<'static, Result<TokenCounts>> {
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
if let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
{
let request = self.build_request(user_prompt, assistant_panel_context.clone(), cx);
match request {
Ok(request) => {
@@ -3139,7 +3148,7 @@ impl CodegenAlternative {
let error_message = result.as_ref().err().map(|error| error.to_string());
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
message_id,
kind: AssistantKind::Inline,

View File

@@ -16,8 +16,8 @@ use gpui::{
};
use language::Buffer;
use language_model::{
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
report_assistant_event,
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
Role, report_assistant_event,
};
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
use prompt_store::PromptBuilder;
@@ -27,7 +27,7 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use terminal::Terminal;
use terminal_view::TerminalView;
use theme::ThemeSettings;
@@ -318,11 +318,13 @@ impl TerminalInlineAssistant {
})
.log_err();
if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
if let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
{
let codegen = assist.codegen.read(cx);
let executor = cx.background_executor().clone();
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
kind: AssistantKind::InlineTerminal,
message_id: codegen.message_id.clone(),
@@ -652,8 +654,8 @@ impl Render for PromptEditor {
format!(
"Using {}",
LanguageModelRegistry::read_global(cx)
.active_model()
.map(|model| model.name().0)
.inline_assistant_model()
.map(|inline_assistant| inline_assistant.model.name().0)
.unwrap_or_else(|| "No model selected".into()),
),
None,
@@ -822,7 +824,9 @@ impl PromptEditor {
fn count_tokens(&mut self, cx: &mut Context<Self>) {
let assist_id = self.id;
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
else {
return;
};
self.pending_token_count = cx.spawn(async move |this, cx| {
@@ -980,7 +984,9 @@ impl PromptEditor {
}
fn render_token_count(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let model = LanguageModelRegistry::read_global(cx).active_model()?;
let model = LanguageModelRegistry::read_global(cx)
.inline_assistant_model()?
.model;
let token_count = self.token_count?;
let max_token_count = model.max_token_count();
@@ -1131,7 +1137,9 @@ impl Codegen {
}
pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut Context<Self>) {
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
let Some(ConfiguredModel { model, .. }) =
LanguageModelRegistry::read_global(cx).inline_assistant_model()
else {
return;
};
@@ -1175,7 +1183,7 @@ impl Codegen {
let error_message = result.as_ref().err().map(|error| error.to_string());
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: None,
kind: AssistantKind::InlineTerminal,
message_id,

View File

@@ -22,6 +22,7 @@ clock.workspace = true
collections.workspace = true
context_server.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
@@ -53,7 +54,9 @@ theme.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
zed_actions.workspace = true
[dev-dependencies]
language_model = { workspace = true, features = ["test-support"] }

View File

@@ -40,7 +40,7 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase};
use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase};
use text::{BufferSnapshot, ToPoint};
use ui::IconName;
use util::{ResultExt, TryFutureExt, post_inc};
@@ -1272,7 +1272,7 @@ impl AssistantContext {
// Assume it will be a Chat request, even though that takes fewer tokens (and risks going over the limit),
// because otherwise you see in the UI that your empty message has a bunch of tokens already used.
let request = self.to_completion_request(RequestType::Chat, cx);
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else {
return;
};
let debounce = self.token_count.is_some();
@@ -1284,10 +1284,12 @@ impl AssistantContext {
.await;
}
let token_count = cx.update(|cx| model.count_tokens(request, cx))?.await?;
let token_count = cx
.update(|cx| model.model.count_tokens(request, cx))?
.await?;
this.update(cx, |this, cx| {
this.token_count = Some(token_count);
this.start_cache_warming(&model, cx);
this.start_cache_warming(&model.model, cx);
cx.notify()
})
}
@@ -2304,14 +2306,16 @@ impl AssistantContext {
cx: &mut Context<Self>,
) -> Option<MessageAnchor> {
let model_registry = LanguageModelRegistry::read_global(cx);
let provider = model_registry.active_provider()?;
let model = model_registry.active_model()?;
let model = model_registry.default_model()?;
let last_message_id = self.get_last_valid_message_id(cx)?;
if !provider.is_authenticated(cx) {
if !model.provider.is_authenticated(cx) {
log::info!("completion provider has no credentials");
return None;
}
let model = model.model;
// Compute which messages to cache, including the last one.
self.mark_cache_anchors(&model.cache_configuration(), false, cx);
@@ -2494,7 +2498,7 @@ impl AssistantContext {
.language()
.map(|language| language.name());
report_assistant_event(
AssistantEvent {
AssistantEventData {
conversation_id: Some(this.id.0.clone()),
kind: AssistantKind::Panel,
phase: AssistantPhase::Response,
@@ -2940,15 +2944,12 @@ impl AssistantContext {
}
pub fn summarize(&mut self, replace_old: bool, cx: &mut Context<Self>) {
let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
return;
};
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
let Some(model) = LanguageModelRegistry::read_global(cx).default_model() else {
return;
};
if replace_old || (self.message_anchors.len() >= 2 && self.summary.is_none()) {
if !provider.is_authenticated(cx) {
if !model.provider.is_authenticated(cx) {
return;
}
@@ -2964,7 +2965,7 @@ impl AssistantContext {
self.pending_summary = cx.spawn(async move |this, cx| {
async move {
let stream = model.stream_completion_text(request, &cx);
let stream = model.model.stream_completion_text(request, &cx);
let mut messages = stream.await?;
let mut replaced = !replace_old;

View File

@@ -18,6 +18,7 @@ use editor::{
scroll::Autoscroll,
};
use editor::{FoldPlaceholder, display_map::CreaseId};
use feature_flags::{Assistant2FeatureFlag, FeatureFlagAppExt as _};
use fs::Fs;
use futures::FutureExt;
use gpui::{
@@ -56,8 +57,7 @@ use ui::{
use util::{ResultExt, maybe};
use workspace::searchable::{Direction, SearchableItemHandle};
use workspace::{
Save, ShowConfiguration, Toast, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
Save, Toast, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
item::{self, FollowableItem, Item, ItemHandle},
notifications::NotificationId,
pane::{self, SaveIntent},
@@ -384,7 +384,9 @@ impl ContextEditor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let provider = LanguageModelRegistry::read_global(cx).active_provider();
let provider = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.provider);
if provider
.as_ref()
.map_or(false, |provider| provider.must_accept_terms(cx))
@@ -1560,7 +1562,7 @@ impl ContextEditor {
})
};
let create_block_properties = |message: &Message| BlockProperties {
height: 2,
height: Some(2),
style: BlockStyle::Sticky,
placement: BlockPlacement::Above(
buffer
@@ -2109,7 +2111,7 @@ impl ContextEditor {
let image = render_image.clone();
anchor.is_valid(&buffer).then(|| BlockProperties {
placement: BlockPlacement::Above(anchor),
height: MAX_HEIGHT_IN_LINES,
height: Some(MAX_HEIGHT_IN_LINES),
style: BlockStyle::Sticky,
render: Arc::new(move |cx| {
let image_size = size_for_image(
@@ -2357,7 +2359,19 @@ impl ContextEditor {
.on_click({
let focus_handle = self.focus_handle(cx).clone();
move |_event, window, cx| {
focus_handle.dispatch_action(&ShowConfiguration, window, cx);
if cx.has_flag::<Assistant2FeatureFlag>() {
focus_handle.dispatch_action(
&zed_actions::agent::OpenConfiguration,
window,
cx,
);
} else {
focus_handle.dispatch_action(
&zed_actions::assistant::ShowConfiguration,
window,
cx,
);
};
}
}),
)
@@ -2395,13 +2409,13 @@ impl ContextEditor {
None => (ButtonStyle::Filled, None),
};
let provider = LanguageModelRegistry::read_global(cx).active_provider();
let model = LanguageModelRegistry::read_global(cx).default_model();
let has_configuration_error = configuration_error(cx).is_some();
let needs_to_accept_terms = self.show_accept_terms
&& provider
&& model
.as_ref()
.map_or(false, |provider| provider.must_accept_terms(cx));
.map_or(false, |model| model.provider.must_accept_terms(cx));
let disabled = has_configuration_error || needs_to_accept_terms;
ButtonLike::new("send_button")
@@ -2454,7 +2468,9 @@ impl ContextEditor {
None => (ButtonStyle::Filled, None),
};
let provider = LanguageModelRegistry::read_global(cx).active_provider();
let provider = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.provider);
let has_configuration_error = configuration_error(cx).is_some();
let needs_to_accept_terms = self.show_accept_terms
@@ -2500,7 +2516,9 @@ impl ContextEditor {
}
fn render_language_model_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
let active_model = LanguageModelRegistry::read_global(cx).active_model();
let active_model = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.model);
let focus_handle = self.editor().focus_handle(cx).clone();
let model_name = match active_model {
Some(model) => model.name().0,
@@ -3020,7 +3038,9 @@ impl EventEmitter<SearchEvent> for ContextEditor {}
impl Render for ContextEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let provider = LanguageModelRegistry::read_global(cx).active_provider();
let provider = LanguageModelRegistry::read_global(cx)
.default_model()
.map(|default| default.provider);
let accept_terms = if self.show_accept_terms {
provider.as_ref().and_then(|provider| {
provider.render_accept_terms(LanguageModelProviderTosView::PromptEditorPopup, cx)
@@ -3616,7 +3636,9 @@ enum TokenState {
fn token_state(context: &Entity<AssistantContext>, cx: &App) -> Option<TokenState> {
const WARNING_TOKEN_THRESHOLD: f32 = 0.8;
let model = LanguageModelRegistry::read_global(cx).active_model()?;
let model = LanguageModelRegistry::read_global(cx)
.default_model()?
.model;
let token_count = context.read(cx).token_count()?;
let max_token_count = model.max_token_count();
@@ -3669,16 +3691,16 @@ pub enum ConfigurationError {
}
fn configuration_error(cx: &App) -> Option<ConfigurationError> {
let provider = LanguageModelRegistry::read_global(cx).active_provider();
let is_authenticated = provider
let model = LanguageModelRegistry::read_global(cx).default_model();
let is_authenticated = model
.as_ref()
.map_or(false, |provider| provider.is_authenticated(cx));
.map_or(false, |model| model.provider.is_authenticated(cx));
if provider.is_some() && is_authenticated {
if model.is_some() && is_authenticated {
return None;
}
if provider.is_none() {
if model.is_none() {
return Some(ConfigurationError::NoProvider);
}
@@ -3703,6 +3725,18 @@ pub fn humanize_token_count(count: usize) -> String {
format!("{}.{}k", thousands, hundreds)
}
}
1_000_000..=9_999_999 => {
let millions = count / 1_000_000;
let hundred_thousands = (count % 1_000_000 + 50_000) / 100_000;
if hundred_thousands == 0 {
format!("{}M", millions)
} else if hundred_thousands == 10 {
format!("{}M", millions + 1)
} else {
format!("{}.{}M", millions, hundred_thousands)
}
}
10_000_000.. => format!("{}M", (count + 500_000) / 1_000_000),
_ => format!("{}k", (count + 500) / 1000),
}
}

View File

@@ -127,6 +127,7 @@ impl SlashCommandCompletionProvider {
new_text,
label: command.label(cx),
icon_path: None,
insert_text_mode: None,
confirm,
source: CompletionSource::Custom,
})
@@ -228,6 +229,7 @@ impl SlashCommandCompletionProvider {
new_text,
documentation: None,
confirm,
insert_text_mode: None,
source: CompletionSource::Custom,
}
})

View File

@@ -1,77 +0,0 @@
# Tool Evals
A framework for evaluating and benchmarking AI assistant performance in the Zed editor.
## Overview
Tool Evals provides a headless environment for running assistants evaluations on code repositories. It automates the process of:
1. Cloning and setting up test repositories
2. Sending prompts to language models
3. Allowing the assistant to use tools to modify code
4. Collecting metrics on performance
5. Evaluating results against known good solutions
## How It Works
The system consists of several key components:
- **Eval**: Loads test cases from the evaluation_data directory, clones repos, and executes evaluations
- **HeadlessAssistant**: Provides a headless environment for running the AI assistant
- **Judge**: Compares AI-generated diffs with reference solutions and scores their functional similarity
The evaluation flow:
1. An evaluation is loaded from the evaluation_data directory
2. The target repository is cloned and checked out at a specific commit
3. A HeadlessAssistant instance is created with the specified language model
4. The user prompt is sent to the assistant
5. The assistant responds and uses tools to modify code
6. Upon completion, a diff is generated from the changes
7. Results are saved including the diff, assistant's response, and performance metrics
8. If a reference solution exists, a Judge evaluates the similarity of the solution
## Setup Requirements
### Prerequisites
- Rust and Cargo
- Git
- Network access to clone repositories
- Appropriate API keys for language models and git services (Anthropic, GitHub, etc.)
### Environment Variables
Ensure you have the required API keys set, either from a dev run of Zed or via these environment variables:
- `ZED_ANTHROPIC_API_KEY` for Claude models
- `ZED_OPENAI_API_KEY` for OpenAI models
- `ZED_GITHUB_API_KEY` for GitHub API (or similar)
## Usage
### Running a Single Evaluation
To run a specific evaluation:
```bash
cargo run -p assistant_eval -- bubbletea-add-set-window-title
```
The arguments are regex patterns for the evaluation names to run, so to run all evaluations that contain `bubbletea`, run:
```bash
cargo run -p assistant_eval -- bubbletea
```
To run all evaluations:
```bash
cargo run -p assistant_eval -- --all
```
## Evaluation Data Structure
Each evaluation should be placed in the `evaluation_data` directory with the following structure:
* `prompt.txt`: The user's prompt.
* `original.diff`: The `git diff` of the change anticipated for this prompt.
* `setup.json`: Information about the repo used for the evaluation.

View File

@@ -1,267 +0,0 @@
use crate::headless_assistant::{HeadlessAppState, HeadlessAssistant};
use agent::RequestKind;
use anyhow::anyhow;
use collections::HashMap;
use gpui::{App, Task};
use language_model::{LanguageModel, TokenUsage};
use serde::{Deserialize, Serialize};
use std::{
fs,
io::Write,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use util::command::new_smol_command;
pub struct Eval {
pub name: String,
pub path: PathBuf,
pub repo_path: PathBuf,
pub eval_setup: EvalSetup,
pub user_prompt: String,
}
#[derive(Debug, Serialize)]
pub struct EvalOutput {
pub diff: String,
pub last_message: String,
pub elapsed_time: Duration,
pub assistant_response_count: usize,
pub tool_use_counts: HashMap<Arc<str>, u32>,
pub token_usage: TokenUsage,
}
#[derive(Deserialize)]
pub struct EvalSetup {
pub url: String,
pub base_sha: String,
}
impl Eval {
/// Loads the eval from a path (typically in `evaluation_data`). Clones and checks out the repo
/// if necessary.
pub async fn load(name: String, path: PathBuf, repos_dir: &Path) -> anyhow::Result<Self> {
let prompt_path = path.join("prompt.txt");
let user_prompt = smol::unblock(|| std::fs::read_to_string(prompt_path)).await?;
let setup_path = path.join("setup.json");
let setup_contents = smol::unblock(|| std::fs::read_to_string(setup_path)).await?;
let eval_setup = serde_json_lenient::from_str_lenient::<EvalSetup>(&setup_contents)?;
let repo_path = repos_dir.join(repo_dir_name(&eval_setup.url));
Ok(Eval {
name,
path,
repo_path,
eval_setup,
user_prompt,
})
}
pub fn run(
self,
app_state: Arc<HeadlessAppState>,
model: Arc<dyn LanguageModel>,
cx: &mut App,
) -> Task<anyhow::Result<EvalOutput>> {
cx.spawn(async move |cx| {
checkout_repo(&self.eval_setup, &self.repo_path).await?;
let (assistant, done_rx) =
cx.update(|cx| HeadlessAssistant::new(app_state.clone(), cx))??;
let _worktree = assistant
.update(cx, |assistant, cx| {
assistant.project.update(cx, |project, cx| {
project.create_worktree(&self.repo_path, true, cx)
})
})?
.await?;
let start_time = std::time::SystemTime::now();
let (system_prompt_context, load_error) = cx
.update(|cx| {
assistant
.read(cx)
.thread
.read(cx)
.load_system_prompt_context(cx)
})?
.await;
if let Some(load_error) = load_error {
return Err(anyhow!("{:?}", load_error));
};
assistant.update(cx, |assistant, cx| {
assistant.thread.update(cx, |thread, cx| {
let context = vec![];
thread.insert_user_message(self.user_prompt.clone(), context, None, cx);
thread.set_system_prompt_context(system_prompt_context);
thread.send_to_model(model, RequestKind::Chat, cx);
});
})?;
done_rx.recv().await??;
let elapsed_time = start_time.elapsed()?;
let diff = query_git(&self.repo_path, vec!["diff"]).await?;
assistant.update(cx, |assistant, cx| {
let thread = assistant.thread.read(cx);
let last_message = thread.messages().last().unwrap();
if last_message.role != language_model::Role::Assistant {
return Err(anyhow!("Last message is not from assistant"));
}
let assistant_response_count = thread
.messages()
.filter(|message| message.role == language_model::Role::Assistant)
.count();
Ok(EvalOutput {
diff,
last_message: last_message.to_string(),
elapsed_time,
assistant_response_count,
tool_use_counts: assistant.tool_use_counts.clone(),
token_usage: thread.cumulative_token_usage(),
})
})?
})
}
}
impl EvalOutput {
// Method to save the output to a directory
pub fn save_to_directory(
&self,
output_dir: &Path,
eval_output_value: String,
) -> anyhow::Result<()> {
// Create the output directory if it doesn't exist
fs::create_dir_all(&output_dir)?;
// Save the diff to a file
let diff_path = output_dir.join("diff.patch");
let mut diff_file = fs::File::create(&diff_path)?;
diff_file.write_all(self.diff.as_bytes())?;
// Save the last message to a file
let message_path = output_dir.join("assistant_response.txt");
let mut message_file = fs::File::create(&message_path)?;
message_file.write_all(self.last_message.as_bytes())?;
// Current metrics for this run
let current_metrics = serde_json::json!({
"elapsed_time_ms": self.elapsed_time.as_millis(),
"assistant_response_count": self.assistant_response_count,
"tool_use_counts": self.tool_use_counts,
"token_usage": self.token_usage,
"eval_output_value": eval_output_value,
});
// Get current timestamp in milliseconds
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis()
.to_string();
// Path to metrics file
let metrics_path = output_dir.join("metrics.json");
// Load existing metrics if the file exists, or create a new object
let mut historical_metrics = if metrics_path.exists() {
let metrics_content = fs::read_to_string(&metrics_path)?;
serde_json::from_str::<serde_json::Value>(&metrics_content)
.unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
// Add new run with timestamp as key
if let serde_json::Value::Object(ref mut map) = historical_metrics {
map.insert(timestamp, current_metrics);
}
// Write updated metrics back to file
let metrics_json = serde_json::to_string_pretty(&historical_metrics)?;
let mut metrics_file = fs::File::create(&metrics_path)?;
metrics_file.write_all(metrics_json.as_bytes())?;
Ok(())
}
}
fn repo_dir_name(url: &str) -> String {
url.trim_start_matches("https://")
.replace(|c: char| !c.is_alphanumeric(), "_")
}
async fn checkout_repo(eval_setup: &EvalSetup, repo_path: &Path) -> anyhow::Result<()> {
if !repo_path.exists() {
smol::unblock({
let repo_path = repo_path.to_path_buf();
|| std::fs::create_dir_all(repo_path)
})
.await?;
run_git(repo_path, vec!["init"]).await?;
run_git(repo_path, vec!["remote", "add", "origin", &eval_setup.url]).await?;
} else {
let actual_origin = query_git(repo_path, vec!["remote", "get-url", "origin"]).await?;
if actual_origin != eval_setup.url {
return Err(anyhow!(
"remote origin {} does not match expected origin {}",
actual_origin,
eval_setup.url
));
}
// TODO: consider including "-x" to remove ignored files. The downside of this is that it will
// also remove build artifacts, and so prevent incremental reuse there.
run_git(repo_path, vec!["clean", "--force", "-d"]).await?;
run_git(repo_path, vec!["reset", "--hard", "HEAD"]).await?;
}
run_git(
repo_path,
vec!["fetch", "--depth", "1", "origin", &eval_setup.base_sha],
)
.await?;
run_git(repo_path, vec!["checkout", &eval_setup.base_sha]).await?;
Ok(())
}
async fn run_git(repo_path: &Path, args: Vec<&str>) -> anyhow::Result<()> {
let exit_status = new_smol_command("git")
.current_dir(repo_path)
.args(args.clone())
.status()
.await?;
if exit_status.success() {
Ok(())
} else {
Err(anyhow!(
"`git {}` failed with {}",
args.join(" "),
exit_status,
))
}
}
async fn query_git(repo_path: &Path, args: Vec<&str>) -> anyhow::Result<String> {
let output = new_smol_command("git")
.current_dir(repo_path)
.args(args.clone())
.output()
.await?;
if output.status.success() {
Ok(String::from_utf8(output.stdout)?.trim().to_string())
} else {
Err(anyhow!(
"`git {}` failed with {}",
args.join(" "),
output.status
))
}
}

View File

@@ -1,121 +0,0 @@
use crate::eval::EvalOutput;
use crate::headless_assistant::send_language_model_request;
use anyhow::anyhow;
use gpui::{App, Task};
use language_model::{
LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role,
};
use std::{path::Path, sync::Arc};
pub struct Judge {
pub original_diff: Option<String>,
#[allow(dead_code)]
pub original_message: Option<String>,
pub model: Arc<dyn LanguageModel>,
}
impl Judge {
pub async fn load(eval_path: &Path, model: Arc<dyn LanguageModel>) -> anyhow::Result<Judge> {
let original_diff_path = eval_path.join("original.diff");
let original_diff = smol::unblock(move || {
if std::fs::exists(&original_diff_path)? {
anyhow::Ok(Some(std::fs::read_to_string(&original_diff_path)?))
} else {
anyhow::Ok(None)
}
});
let original_message_path = eval_path.join("original_message.txt");
let original_message = smol::unblock(move || {
if std::fs::exists(&original_message_path)? {
anyhow::Ok(Some(std::fs::read_to_string(&original_message_path)?))
} else {
anyhow::Ok(None)
}
});
Ok(Self {
original_diff: original_diff.await?,
original_message: original_message.await?,
model,
})
}
pub fn run(&self, eval_output: &EvalOutput, cx: &mut App) -> Task<anyhow::Result<String>> {
let Some(original_diff) = self.original_diff.as_ref() else {
return Task::ready(Err(anyhow!("No original.diff found")));
};
// TODO: check for empty diff?
let prompt = diff_comparison_prompt(&original_diff, &eval_output.diff);
let request = LanguageModelRequest {
messages: vec![LanguageModelRequestMessage {
role: Role::User,
content: vec![MessageContent::Text(prompt)],
cache: false,
}],
temperature: Some(0.0),
tools: Vec::new(),
stop: Vec::new(),
};
let model = self.model.clone();
cx.spawn(async move |cx| send_language_model_request(model, request, cx).await)
}
}
pub fn diff_comparison_prompt(original_diff: &str, new_diff: &str) -> String {
format!(
r#"# Git Diff Similarity Evaluation Template
## Instructions
Compare the two diffs and score them between 0.0 and 1.0 based on their functional similarity.
- 1.0 = Perfect functional match (achieves identical results)
- 0.0 = No functional similarity whatsoever
## Evaluation Criteria
Please consider the following aspects in order of importance:
1. **Functional Equivalence (60%)**
- Do both diffs achieve the same end result?
- Are the changes functionally equivalent despite possibly using different approaches?
- Do the modifications address the same issues or implement the same features?
2. **Logical Structure (20%)**
- Are the logical flows similar?
- Do the modifications affect the same code paths?
- Are control structures (if/else, loops, etc.) modified in similar ways?
3. **Code Content (15%)**
- Are similar lines added/removed?
- Are the same variables, functions, or methods being modified?
- Are the same APIs or libraries being used?
4. **File Layout (5%)**
- Are the same files being modified?
- Are changes occurring in similar locations within files?
## Input
Original Diff:
```git
{}
```
New Diff:
```git
{}
```
## Output Format
THE ONLY OUTPUT SHOULD BE A SCORE BETWEEN 0.0 AND 1.0.
Example output:
0.85"#,
original_diff, new_diff
)
}

View File

@@ -1,243 +0,0 @@
mod eval;
mod headless_assistant;
mod judge;
use clap::Parser;
use eval::{Eval, EvalOutput};
use futures::future;
use gpui::{Application, AsyncApp};
use headless_assistant::{HeadlessAppState, authenticate_model_provider, find_model};
use itertools::Itertools;
use judge::Judge;
use language_model::{LanguageModel, LanguageModelRegistry};
use regex::Regex;
use reqwest_client::ReqwestClient;
use std::{cmp, path::PathBuf, sync::Arc};
#[derive(Parser, Debug)]
#[command(
name = "assistant_eval",
disable_version_flag = true,
before_help = "Tool eval runner"
)]
struct Args {
/// Regexes to match the names of evals to run.
eval_name_regexes: Vec<String>,
/// Runs all evals in `evaluation_data`, causes the regex to be ignored.
#[arg(long)]
all: bool,
/// Name of the model (default: "claude-3-7-sonnet-latest")
#[arg(long, default_value = "claude-3-7-sonnet-latest")]
model_name: String,
/// Name of the editor model (default: value of `--model_name`).
#[arg(long)]
editor_model_name: Option<String>,
/// Name of the judge model (default: value of `--model_name`).
#[arg(long)]
judge_model_name: Option<String>,
/// Number of evaluations to run concurrently (default: 10)
#[arg(short, long, default_value = "10")]
concurrency: usize,
}
fn main() {
env_logger::init();
let args = Args::parse();
let http_client = Arc::new(ReqwestClient::new());
let app = Application::headless().with_http_client(http_client.clone());
let crate_dir = PathBuf::from("../zed-agent-bench");
let evaluation_data_dir = crate_dir.join("evaluation_data").canonicalize().unwrap();
let repos_dir = crate_dir.join("repos");
if !repos_dir.exists() {
std::fs::create_dir_all(&repos_dir).unwrap();
}
let repos_dir = repos_dir.canonicalize().unwrap();
let all_evals = std::fs::read_dir(&evaluation_data_dir)
.unwrap()
.map(|path| path.unwrap().file_name().to_string_lossy().to_string())
.collect::<Vec<_>>();
let evals_to_run = if args.all {
all_evals
} else {
args.eval_name_regexes
.into_iter()
.map(|regex_string| Regex::new(&regex_string).unwrap())
.flat_map(|regex| {
all_evals
.iter()
.filter(|eval_name| regex.is_match(eval_name))
.cloned()
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
};
if evals_to_run.is_empty() {
panic!("Names of evals to run must be provided or `--all` specified");
}
println!("Will run the following evals: {evals_to_run:?}");
println!("Running up to {} evals concurrently", args.concurrency);
let editor_model_name = if let Some(model_name) = args.editor_model_name {
model_name
} else {
args.model_name.clone()
};
let judge_model_name = if let Some(model_name) = args.judge_model_name {
model_name
} else {
args.model_name.clone()
};
app.run(move |cx| {
let app_state = headless_assistant::init(cx);
let model = find_model(&args.model_name, cx).unwrap();
let editor_model = find_model(&editor_model_name, cx).unwrap();
let judge_model = find_model(&judge_model_name, cx).unwrap();
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry.set_active_model(Some(model.clone()), cx);
registry.set_editor_model(Some(editor_model.clone()), cx);
});
let model_provider_id = model.provider_id();
let editor_model_provider_id = editor_model.provider_id();
let judge_model_provider_id = judge_model.provider_id();
cx.spawn(async move |cx| {
// Authenticate all model providers first
cx.update(|cx| authenticate_model_provider(model_provider_id.clone(), cx))
.unwrap()
.await
.unwrap();
cx.update(|cx| authenticate_model_provider(editor_model_provider_id.clone(), cx))
.unwrap()
.await
.unwrap();
cx.update(|cx| authenticate_model_provider(judge_model_provider_id.clone(), cx))
.unwrap()
.await
.unwrap();
let eval_load_futures = evals_to_run
.into_iter()
.map(|eval_name| {
let eval_path = evaluation_data_dir.join(&eval_name);
let load_future = Eval::load(eval_name.clone(), eval_path, &repos_dir);
async move {
match load_future.await {
Ok(eval) => Some(eval),
Err(err) => {
// TODO: Persist errors / surface errors at the end.
println!("Error loading {eval_name}: {err}");
None
}
}
}
})
.collect::<Vec<_>>();
let loaded_evals = future::join_all(eval_load_futures)
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
// The evals need to be loaded and grouped by URL before concurrently running, since
// evals that use the same remote URL will use the same working directory.
let mut evals_grouped_by_url: Vec<Vec<Eval>> = loaded_evals
.into_iter()
.map(|eval| (eval.eval_setup.url.clone(), eval))
.into_group_map()
.into_values()
.collect::<Vec<_>>();
// Sort groups in descending order, so that bigger groups start first.
evals_grouped_by_url.sort_by_key(|evals| cmp::Reverse(evals.len()));
let result_futures = evals_grouped_by_url
.into_iter()
.map(|evals| {
let model = model.clone();
let judge_model = judge_model.clone();
let app_state = app_state.clone();
let cx = cx.clone();
async move {
let mut results = Vec::new();
for eval in evals {
let name = eval.name.clone();
println!("Starting eval named {}", name);
let result = run_eval(
eval,
model.clone(),
judge_model.clone(),
app_state.clone(),
cx.clone(),
)
.await;
results.push((name, result));
}
results
}
})
.collect::<Vec<_>>();
let results = future::join_all(result_futures)
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
// Process results in order of completion
for (eval_name, result) in results {
match result {
Ok((eval_output, judge_output)) => {
println!("Generated diff for {eval_name}:\n");
println!("{}\n", eval_output.diff);
println!("Last message for {eval_name}:\n");
println!("{}\n", eval_output.last_message);
println!("Elapsed time: {:?}", eval_output.elapsed_time);
println!(
"Assistant response count: {}",
eval_output.assistant_response_count
);
println!("Tool use counts: {:?}", eval_output.tool_use_counts);
println!("Judge output for {eval_name}: {judge_output}");
}
Err(err) => {
// TODO: Persist errors / surface errors at the end.
println!("Error running {eval_name}: {err}");
}
}
}
cx.update(|cx| cx.quit()).unwrap();
})
.detach();
});
println!("Done running evals");
}
async fn run_eval(
eval: Eval,
model: Arc<dyn LanguageModel>,
judge_model: Arc<dyn LanguageModel>,
app_state: Arc<HeadlessAppState>,
cx: AsyncApp,
) -> anyhow::Result<(EvalOutput, String)> {
let path = eval.path.clone();
let judge = Judge::load(&path, judge_model).await?;
let eval_output = cx.update(|cx| eval.run(app_state, model, cx))?.await?;
let judge_output = cx.update(|cx| judge.run(&eval_output, cx))?.await?;
eval_output.save_to_directory(&path, judge_output.to_string())?;
Ok((eval_output, judge_output))
}

View File

@@ -26,6 +26,7 @@ deepseek = { workspace = true, features = ["schemars"] }
schemars.workspace = true
serde.workspace = true
settings.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
fs.workspace = true

View File

@@ -77,7 +77,9 @@ pub struct AssistantSettings {
pub default_width: Pixels,
pub default_height: Pixels,
pub default_model: LanguageModelSelection,
pub editor_model: LanguageModelSelection,
pub inline_assistant_model: Option<LanguageModelSelection>,
pub commit_message_model: Option<LanguageModelSelection>,
pub thread_summary_model: Option<LanguageModelSelection>,
pub inline_alternatives: Vec<LanguageModelSelection>,
pub using_outdated_settings_version: bool,
pub enable_experimental_live_diffs: bool,
@@ -95,13 +97,25 @@ impl AssistantSettings {
cx.is_staff() || self.enable_experimental_live_diffs
}
pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
self.inline_assistant_model = Some(LanguageModelSelection { provider, model });
}
pub fn set_commit_message_model(&mut self, provider: String, model: String) {
self.commit_message_model = Some(LanguageModelSelection { provider, model });
}
pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
self.thread_summary_model = Some(LanguageModelSelection { provider, model });
}
}
/// Assistant panel settings
#[derive(Clone, Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum AssistantSettingsContent {
Versioned(VersionedAssistantSettingsContent),
Versioned(Box<VersionedAssistantSettingsContent>),
Legacy(LegacyAssistantSettingsContent),
}
@@ -121,14 +135,14 @@ impl JsonSchema for AssistantSettingsContent {
impl Default for AssistantSettingsContent {
fn default() -> Self {
Self::Versioned(VersionedAssistantSettingsContent::default())
Self::Versioned(Box::new(VersionedAssistantSettingsContent::default()))
}
}
impl AssistantSettingsContent {
pub fn is_version_outdated(&self) -> bool {
match self {
AssistantSettingsContent::Versioned(settings) => match settings {
AssistantSettingsContent::Versioned(settings) => match **settings {
VersionedAssistantSettingsContent::V1(_) => true,
VersionedAssistantSettingsContent::V2(_) => false,
},
@@ -138,8 +152,8 @@ impl AssistantSettingsContent {
fn upgrade(&self) -> AssistantSettingsContentV2 {
match self {
AssistantSettingsContent::Versioned(settings) => match settings {
VersionedAssistantSettingsContent::V1(settings) => AssistantSettingsContentV2 {
AssistantSettingsContent::Versioned(settings) => match **settings {
VersionedAssistantSettingsContent::V1(ref settings) => AssistantSettingsContentV2 {
enabled: settings.enabled,
button: settings.button,
dock: settings.dock,
@@ -186,7 +200,9 @@ impl AssistantSettingsContent {
})
}
}),
editor_model: None,
inline_assistant_model: None,
commit_message_model: None,
thread_summary_model: None,
inline_alternatives: None,
enable_experimental_live_diffs: None,
default_profile: None,
@@ -194,7 +210,7 @@ impl AssistantSettingsContent {
always_allow_tool_actions: None,
notify_when_agent_waiting: None,
},
VersionedAssistantSettingsContent::V2(settings) => settings.clone(),
VersionedAssistantSettingsContent::V2(ref settings) => settings.clone(),
},
AssistantSettingsContent::Legacy(settings) => AssistantSettingsContentV2 {
enabled: None,
@@ -211,7 +227,9 @@ impl AssistantSettingsContent {
.id()
.to_string(),
}),
editor_model: None,
inline_assistant_model: None,
commit_message_model: None,
thread_summary_model: None,
inline_alternatives: None,
enable_experimental_live_diffs: None,
default_profile: None,
@@ -224,11 +242,11 @@ impl AssistantSettingsContent {
pub fn set_dock(&mut self, dock: AssistantDockPosition) {
match self {
AssistantSettingsContent::Versioned(settings) => match settings {
VersionedAssistantSettingsContent::V1(settings) => {
AssistantSettingsContent::Versioned(settings) => match **settings {
VersionedAssistantSettingsContent::V1(ref mut settings) => {
settings.dock = Some(dock);
}
VersionedAssistantSettingsContent::V2(settings) => {
VersionedAssistantSettingsContent::V2(ref mut settings) => {
settings.dock = Some(dock);
}
},
@@ -243,77 +261,79 @@ impl AssistantSettingsContent {
let provider = language_model.provider_id().0.to_string();
match self {
AssistantSettingsContent::Versioned(settings) => match settings {
VersionedAssistantSettingsContent::V1(settings) => match provider.as_ref() {
"zed.dev" => {
log::warn!("attempted to set zed.dev model on outdated settings");
}
"anthropic" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::Anthropic { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::Anthropic {
default_model: AnthropicModel::from_id(&model).ok(),
api_url,
});
}
"ollama" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::Ollama { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::Ollama {
default_model: Some(ollama::Model::new(&model, None, None)),
api_url,
});
}
"lmstudio" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::LmStudio { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::LmStudio {
default_model: Some(lmstudio::Model::new(&model, None, None)),
api_url,
});
}
"openai" => {
let (api_url, available_models) = match &settings.provider {
Some(AssistantProviderContentV1::OpenAi {
AssistantSettingsContent::Versioned(settings) => match **settings {
VersionedAssistantSettingsContent::V1(ref mut settings) => {
match provider.as_ref() {
"zed.dev" => {
log::warn!("attempted to set zed.dev model on outdated settings");
}
"anthropic" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::Anthropic { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::Anthropic {
default_model: AnthropicModel::from_id(&model).ok(),
api_url,
});
}
"ollama" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::Ollama { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::Ollama {
default_model: Some(ollama::Model::new(&model, None, None)),
api_url,
});
}
"lmstudio" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::LmStudio { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::LmStudio {
default_model: Some(lmstudio::Model::new(&model, None, None)),
api_url,
});
}
"openai" => {
let (api_url, available_models) = match &settings.provider {
Some(AssistantProviderContentV1::OpenAi {
api_url,
available_models,
..
}) => (api_url.clone(), available_models.clone()),
_ => (None, None),
};
settings.provider = Some(AssistantProviderContentV1::OpenAi {
default_model: OpenAiModel::from_id(&model).ok(),
api_url,
available_models,
..
}) => (api_url.clone(), available_models.clone()),
_ => (None, None),
};
settings.provider = Some(AssistantProviderContentV1::OpenAi {
default_model: OpenAiModel::from_id(&model).ok(),
api_url,
available_models,
});
});
}
"deepseek" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::DeepSeek { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::DeepSeek {
default_model: DeepseekModel::from_id(&model).ok(),
api_url,
});
}
_ => {}
}
"deepseek" => {
let api_url = match &settings.provider {
Some(AssistantProviderContentV1::DeepSeek { api_url, .. }) => {
api_url.clone()
}
_ => None,
};
settings.provider = Some(AssistantProviderContentV1::DeepSeek {
default_model: DeepseekModel::from_id(&model).ok(),
api_url,
});
}
_ => {}
},
VersionedAssistantSettingsContent::V2(settings) => {
}
VersionedAssistantSettingsContent::V2(ref mut settings) => {
settings.default_model = Some(LanguageModelSelection { provider, model });
}
},
@@ -325,23 +345,48 @@ impl AssistantSettingsContent {
}
}
pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
if let AssistantSettingsContent::Versioned(boxed) = self {
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
settings.inline_assistant_model = Some(LanguageModelSelection { provider, model });
}
}
}
pub fn set_commit_message_model(&mut self, provider: String, model: String) {
if let AssistantSettingsContent::Versioned(boxed) = self {
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
settings.commit_message_model = Some(LanguageModelSelection { provider, model });
}
}
}
pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
if let AssistantSettingsContent::Versioned(boxed) = self {
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
settings.thread_summary_model = Some(LanguageModelSelection { provider, model });
}
}
}
pub fn set_always_allow_tool_actions(&mut self, allow: bool) {
let AssistantSettingsContent::Versioned(VersionedAssistantSettingsContent::V2(settings)) =
self
else {
let AssistantSettingsContent::Versioned(boxed) = self else {
return;
};
settings.always_allow_tool_actions = Some(allow);
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
settings.always_allow_tool_actions = Some(allow);
}
}
pub fn set_profile(&mut self, profile_id: AgentProfileId) {
let AssistantSettingsContent::Versioned(VersionedAssistantSettingsContent::V2(settings)) =
self
else {
let AssistantSettingsContent::Versioned(boxed) = self else {
return;
};
settings.default_profile = Some(profile_id);
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
settings.default_profile = Some(profile_id);
}
}
pub fn create_profile(
@@ -349,37 +394,37 @@ impl AssistantSettingsContent {
profile_id: AgentProfileId,
profile: AgentProfile,
) -> Result<()> {
let AssistantSettingsContent::Versioned(VersionedAssistantSettingsContent::V2(settings)) =
self
else {
let AssistantSettingsContent::Versioned(boxed) = self else {
return Ok(());
};
let profiles = settings.profiles.get_or_insert_default();
if profiles.contains_key(&profile_id) {
bail!("profile with ID '{profile_id}' already exists");
}
if let VersionedAssistantSettingsContent::V2(ref mut settings) = **boxed {
let profiles = settings.profiles.get_or_insert_default();
if profiles.contains_key(&profile_id) {
bail!("profile with ID '{profile_id}' already exists");
}
profiles.insert(
profile_id,
AgentProfileContent {
name: profile.name.into(),
tools: profile.tools,
enable_all_context_servers: Some(profile.enable_all_context_servers),
context_servers: profile
.context_servers
.into_iter()
.map(|(server_id, preset)| {
(
server_id,
ContextServerPresetContent {
tools: preset.tools,
},
)
})
.collect(),
},
);
profiles.insert(
profile_id,
AgentProfileContent {
name: profile.name.into(),
tools: profile.tools,
enable_all_context_servers: Some(profile.enable_all_context_servers),
context_servers: profile
.context_servers
.into_iter()
.map(|(server_id, preset)| {
(
server_id,
ContextServerPresetContent {
tools: preset.tools,
},
)
})
.collect(),
},
);
}
Ok(())
}
@@ -403,7 +448,9 @@ impl Default for VersionedAssistantSettingsContent {
default_width: None,
default_height: None,
default_model: None,
editor_model: None,
inline_assistant_model: None,
commit_message_model: None,
thread_summary_model: None,
inline_alternatives: None,
enable_experimental_live_diffs: None,
default_profile: None,
@@ -436,10 +483,14 @@ pub struct AssistantSettingsContentV2 {
///
/// Default: 320
default_height: Option<f32>,
/// The default model to use when creating new chats.
/// The default model to use when creating new chats and for other features when a specific model is not specified.
default_model: Option<LanguageModelSelection>,
/// The model to use when applying edits from the assistant.
editor_model: Option<LanguageModelSelection>,
/// Model to use for the inline assistant. Defaults to default_model when not specified.
inline_assistant_model: Option<LanguageModelSelection>,
/// Model to use for generating git commit messages. Defaults to default_model when not specified.
commit_message_model: Option<LanguageModelSelection>,
/// Model to use for generating thread summaries. Defaults to default_model when not specified.
thread_summary_model: Option<LanguageModelSelection>,
/// Additional models with which to generate alternatives when performing inline assists.
inline_alternatives: Option<Vec<LanguageModelSelection>>,
/// Enable experimental live diffs in the assistant panel.
@@ -601,7 +652,15 @@ impl Settings for AssistantSettings {
value.default_height.map(Into::into),
);
merge(&mut settings.default_model, value.default_model);
merge(&mut settings.editor_model, value.editor_model);
settings.inline_assistant_model = value
.inline_assistant_model
.or(settings.inline_assistant_model.take());
settings.commit_message_model = value
.commit_message_model
.or(settings.commit_message_model.take());
settings.thread_summary_model = value
.thread_summary_model
.or(settings.thread_summary_model.take());
merge(&mut settings.inline_alternatives, value.inline_alternatives);
merge(
&mut settings.enable_experimental_live_diffs,
@@ -692,16 +751,15 @@ mod tests {
settings::SettingsStore::global(cx).update_settings_file::<AssistantSettings>(
fs.clone(),
|settings, _| {
*settings = AssistantSettingsContent::Versioned(
*settings = AssistantSettingsContent::Versioned(Box::new(
VersionedAssistantSettingsContent::V2(AssistantSettingsContentV2 {
default_model: Some(LanguageModelSelection {
provider: "test-provider".into(),
model: "gpt-99".into(),
}),
editor_model: Some(LanguageModelSelection {
provider: "test-provider".into(),
model: "gpt-99".into(),
}),
inline_assistant_model: None,
commit_message_model: None,
thread_summary_model: None,
inline_alternatives: None,
enabled: None,
button: None,
@@ -714,7 +772,7 @@ mod tests {
always_allow_tool_actions: None,
notify_when_agent_waiting: None,
}),
)
))
},
);
});

View File

@@ -26,6 +26,7 @@ serde.workspace = true
serde_json.workspace = true
ui.workspace = true
workspace.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }

View File

@@ -42,6 +42,7 @@ ui.workspace = true
util.workspace = true
workspace.workspace = true
worktree.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
env_logger.workspace = true

View File

@@ -28,6 +28,7 @@ serde.workspace = true
serde_json.workspace = true
text.workspace = true
util.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
buffer_diff = { workspace = true, features = ["test-support"] }

View File

@@ -1,31 +1,31 @@
use anyhow::{Context as _, Result};
use buffer_diff::BufferDiff;
use collections::{BTreeMap, HashSet};
use collections::BTreeMap;
use futures::{StreamExt, channel::mpsc};
use gpui::{App, AppContext, AsyncApp, Context, Entity, Subscription, Task, WeakEntity};
use language::{Anchor, Buffer, BufferEvent, DiskState, Point};
use project::{Project, ProjectItem};
use std::{cmp, ops::Range, sync::Arc};
use text::{Edit, Patch, Rope};
use util::RangeExt;
/// Tracks actions performed by tools in a thread
pub struct ActionLog {
/// Buffers that user manually added to the context, and whose content has
/// changed since the model last saw them.
stale_buffers_in_context: HashSet<Entity<Buffer>>,
/// Buffers that we want to notify the model about when they change.
tracked_buffers: BTreeMap<Entity<Buffer>, TrackedBuffer>,
/// Has the model edited a file since it last checked diagnostics?
edited_since_project_diagnostics_check: bool,
/// The project this action log is associated with
project: Entity<Project>,
}
impl ActionLog {
/// Creates a new, empty action log.
pub fn new() -> Self {
/// Creates a new, empty action log associated with the given project.
pub fn new(project: Entity<Project>) -> Self {
Self {
stale_buffers_in_context: HashSet::default(),
tracked_buffers: BTreeMap::default(),
edited_since_project_diagnostics_check: false,
project,
}
}
@@ -259,6 +259,11 @@ impl ActionLog {
self.track_buffer(buffer, false, cx);
}
/// Track a buffer that was added as context, so we can notify the model about user edits.
pub fn buffer_added_as_context(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
self.track_buffer(buffer, false, cx);
}
/// Track a buffer as read, so we can notify the model about user edits.
pub fn will_create_buffer(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
self.track_buffer(buffer.clone(), true, cx);
@@ -268,7 +273,6 @@ impl ActionLog {
/// Mark a buffer as edited, so we can refresh it in the context
pub fn buffer_edited(&mut self, buffer: Entity<Buffer>, cx: &mut Context<Self>) {
self.edited_since_project_diagnostics_check = true;
self.stale_buffers_in_context.insert(buffer.clone());
let tracked_buffer = self.track_buffer(buffer.clone(), false, cx);
if let TrackedBufferStatus::Deleted = tracked_buffer.status {
@@ -324,14 +328,14 @@ impl ActionLog {
{
true
} else {
let old_bytes = tracked_buffer
let old_range = tracked_buffer
.base_text
.point_to_offset(Point::new(edit.old.start, 0))
..tracked_buffer.base_text.point_to_offset(cmp::min(
Point::new(edit.old.end, 0),
tracked_buffer.base_text.max_point(),
));
let new_bytes = tracked_buffer
let new_range = tracked_buffer
.snapshot
.point_to_offset(Point::new(edit.new.start, 0))
..tracked_buffer.snapshot.point_to_offset(cmp::min(
@@ -339,10 +343,10 @@ impl ActionLog {
tracked_buffer.snapshot.max_point(),
));
tracked_buffer.base_text.replace(
old_bytes,
old_range,
&tracked_buffer
.snapshot
.text_for_range(new_bytes)
.text_for_range(new_range)
.collect::<String>(),
);
delta += edit.new_len() as i32 - edit.old_len() as i32;
@@ -354,6 +358,87 @@ impl ActionLog {
}
}
pub fn reject_edits_in_range(
&mut self,
buffer: Entity<Buffer>,
buffer_range: Range<impl language::ToPoint>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else {
return Task::ready(Ok(()));
};
match tracked_buffer.status {
TrackedBufferStatus::Created => {
let delete = buffer
.read(cx)
.entry_id(cx)
.and_then(|entry_id| {
self.project
.update(cx, |project, cx| project.delete_entry(entry_id, false, cx))
})
.unwrap_or(Task::ready(Ok(())));
self.tracked_buffers.remove(&buffer);
cx.notify();
delete
}
TrackedBufferStatus::Deleted => {
buffer.update(cx, |buffer, cx| {
buffer.set_text(tracked_buffer.base_text.to_string(), cx)
});
let save = self
.project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
// Clear all tracked changes for this buffer and start over as if we just read it.
self.tracked_buffers.remove(&buffer);
self.track_buffer(buffer.clone(), false, cx);
cx.notify();
save
}
TrackedBufferStatus::Modified => {
buffer.update(cx, |buffer, cx| {
let buffer_range =
buffer_range.start.to_point(buffer)..buffer_range.end.to_point(buffer);
let mut edits_to_revert = Vec::new();
for edit in tracked_buffer.unreviewed_changes.edits() {
if buffer_range.end.row < edit.new.start {
break;
} else if buffer_range.start.row > edit.new.end {
continue;
}
let old_range = tracked_buffer
.base_text
.point_to_offset(Point::new(edit.old.start, 0))
..tracked_buffer.base_text.point_to_offset(cmp::min(
Point::new(edit.old.end, 0),
tracked_buffer.base_text.max_point(),
));
let old_text = tracked_buffer
.base_text
.chunks_in_range(old_range)
.collect::<String>();
let new_range = tracked_buffer
.snapshot
.anchor_before(Point::new(edit.new.start, 0))
..tracked_buffer.snapshot.anchor_after(cmp::min(
Point::new(edit.new.end, 0),
tracked_buffer.snapshot.max_point(),
));
edits_to_revert.push((new_range, old_text));
}
buffer.edit(edits_to_revert, None, cx);
});
self.project
.update(cx, |project, cx| project.save_buffer(buffer, cx))
}
}
}
pub fn keep_all_edits(&mut self, cx: &mut Context<Self>) {
self.tracked_buffers
.retain(|_buffer, tracked_buffer| match tracked_buffer.status {
@@ -391,11 +476,6 @@ impl ActionLog {
})
.map(|(buffer, _)| buffer)
}
/// Takes and returns the set of buffers pending refresh, clearing internal state.
pub fn take_stale_buffers_in_context(&mut self) -> HashSet<Entity<Buffer>> {
std::mem::take(&mut self.stale_buffers_in_context)
}
}
fn apply_non_conflicting_edits(
@@ -580,9 +660,22 @@ mod tests {
}
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
}
#[gpui::test(iterations = 10)]
async fn test_keep_edits(cx: &mut TestAppContext) {
let action_log = cx.new(|_| ActionLog::new());
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
cx.update(|cx| {
@@ -648,7 +741,11 @@ mod tests {
#[gpui::test(iterations = 10)]
async fn test_deletions(cx: &mut TestAppContext) {
let action_log = cx.new(|_| ActionLog::new());
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno\npqr", cx));
cx.update(|cx| {
@@ -718,7 +815,11 @@ mod tests {
#[gpui::test(iterations = 10)]
async fn test_overlapping_user_edits(cx: &mut TestAppContext) {
let action_log = cx.new(|_| ActionLog::new());
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi\njkl\nmno", cx));
cx.update(|cx| {
@@ -802,15 +903,12 @@ mod tests {
}
#[gpui::test(iterations = 10)]
async fn test_creation(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
async fn test_creating_files(cx: &mut TestAppContext) {
init_test(cx);
let action_log = cx.new(|_| ActionLog::new());
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({})).await;
@@ -869,12 +967,7 @@ mod tests {
#[gpui::test(iterations = 10)]
async fn test_deleting_files(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
@@ -891,7 +984,7 @@ mod tests {
.read_with(cx, |project, cx| project.find_project_path("dir/file2", cx))
.unwrap();
let action_log = cx.new(|_| ActionLog::new());
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let buffer1 = project
.update(cx, |project, cx| {
project.open_buffer(file1_path.clone(), cx)
@@ -981,15 +1074,222 @@ mod tests {
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
#[gpui::test(iterations = 10)]
async fn test_reject_edits(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({"file": "abc\ndef\nghi\njkl\nmno"}))
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let file_path = project
.read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path, cx))
.await
.unwrap();
cx.update(|cx| {
action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
buffer.update(cx, |buffer, cx| {
buffer
.edit([(Point::new(1, 1)..Point::new(1, 2), "E\nXYZ")], None, cx)
.unwrap()
});
buffer.update(cx, |buffer, cx| {
buffer
.edit([(Point::new(5, 2)..Point::new(5, 3), "O")], None, cx)
.unwrap()
});
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
});
cx.run_until_parked();
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.text()),
"abc\ndE\nXYZf\nghi\njkl\nmnO"
);
assert_eq!(
unreviewed_hunks(&action_log, cx),
vec![(
buffer.clone(),
vec![
HunkStatus {
range: Point::new(1, 0)..Point::new(3, 0),
diff_status: DiffHunkStatusKind::Modified,
old_text: "def\n".into(),
},
HunkStatus {
range: Point::new(5, 0)..Point::new(5, 3),
diff_status: DiffHunkStatusKind::Modified,
old_text: "mno".into(),
}
],
)]
);
action_log
.update(cx, |log, cx| {
log.reject_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx)
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.text()),
"abc\ndef\nghi\njkl\nmnO"
);
assert_eq!(
unreviewed_hunks(&action_log, cx),
vec![(
buffer.clone(),
vec![HunkStatus {
range: Point::new(4, 0)..Point::new(4, 3),
diff_status: DiffHunkStatusKind::Modified,
old_text: "mno".into(),
}],
)]
);
action_log
.update(cx, |log, cx| {
log.reject_edits_in_range(buffer.clone(), Point::new(4, 0)..Point::new(4, 0), cx)
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
buffer.read_with(cx, |buffer, _| buffer.text()),
"abc\ndef\nghi\njkl\nmno"
);
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
#[gpui::test(iterations = 10)]
async fn test_reject_deleted_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({"file": "content"}))
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let file_path = project
.read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path.clone(), cx))
.await
.unwrap();
cx.update(|cx| {
action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx));
});
project
.update(cx, |project, cx| {
project.delete_file(file_path.clone(), false, cx)
})
.unwrap()
.await
.unwrap();
cx.run_until_parked();
assert!(!fs.is_file(path!("/dir/file").as_ref()).await);
assert_eq!(
unreviewed_hunks(&action_log, cx),
vec![(
buffer.clone(),
vec![HunkStatus {
range: Point::new(0, 0)..Point::new(0, 0),
diff_status: DiffHunkStatusKind::Deleted,
old_text: "content".into(),
}]
)]
);
action_log
.update(cx, |log, cx| {
log.reject_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(0, 0), cx)
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(buffer.read_with(cx, |buffer, _| buffer.text()), "content");
assert!(fs.is_file(path!("/dir/file").as_ref()).await);
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
#[gpui::test(iterations = 10)]
async fn test_reject_created_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let file_path = project
.read_with(cx, |project, cx| {
project.find_project_path("dir/new_file", cx)
})
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path, cx))
.await
.unwrap();
cx.update(|cx| {
buffer.update(cx, |buffer, cx| buffer.set_text("content", cx));
action_log.update(cx, |log, cx| log.will_create_buffer(buffer.clone(), cx));
});
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
.await
.unwrap();
assert!(fs.is_file(path!("/dir/new_file").as_ref()).await);
cx.run_until_parked();
assert_eq!(
unreviewed_hunks(&action_log, cx),
vec![(
buffer.clone(),
vec![HunkStatus {
range: Point::new(0, 0)..Point::new(0, 7),
diff_status: DiffHunkStatusKind::Added,
old_text: "".into(),
}],
)]
);
action_log
.update(cx, |log, cx| {
log.reject_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(0, 11), cx)
})
.await
.unwrap();
cx.run_until_parked();
assert!(!fs.is_file(path!("/dir/new_file").as_ref()).await);
assert_eq!(unreviewed_hunks(&action_log, cx), vec![]);
}
#[gpui::test(iterations = 100)]
async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) {
init_test(cx);
let operations = env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(20);
let action_log = cx.new(|_| ActionLog::new());
let text = RandomCharIter::new(&mut rng).take(50).collect::<String>();
let buffer = cx.new(|cx| Buffer::local(text, cx));
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({"file": text})).await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let file_path = project
.read_with(cx, |project, cx| project.find_project_path("dir/file", cx))
.unwrap();
let buffer = project
.update(cx, |project, cx| project.open_buffer(file_path, cx))
.await
.unwrap();
action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
for _ in 0..operations {
@@ -997,10 +1297,20 @@ mod tests {
0..25 => {
action_log.update(cx, |log, cx| {
let range = buffer.read(cx).random_byte_range(0, &mut rng);
log::info!("keeping all edits in range {:?}", range);
log::info!("keeping edits in range {:?}", range);
log.keep_edits_in_range(buffer.clone(), range, cx)
});
}
25..50 => {
action_log
.update(cx, |log, cx| {
let range = buffer.read(cx).random_byte_range(0, &mut rng);
log::info!("rejecting edits in range {:?}", range);
log.reject_edits_in_range(buffer.clone(), range, cx)
})
.await
.unwrap();
}
_ => {
let is_agent_change = rng.gen_bool(0.5);
if is_agent_change {

View File

@@ -16,7 +16,6 @@ anyhow.workspace = true
assistant_tool.workspace = true
chrono.workspace = true
collections.workspace = true
feature_flags.workspace = true
futures.workspace = true
gpui.workspace = true
html_to_markdown.workspace = true
@@ -24,21 +23,16 @@ http_client.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
lsp.workspace = true
project.workspace = true
regex.workspace = true
release_channel.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
worktree.workspace = true
open = { workspace = true }
workspace-hack.workspace = true
[dev-dependencies]
collections = { workspace = true, features = ["test-support"] }

View File

@@ -1,13 +1,11 @@
mod bash_tool;
mod batch_tool;
mod code_symbol_iter;
mod code_symbols_tool;
mod copy_path_tool;
mod create_directory_tool;
mod create_file_tool;
mod delete_path_tool;
mod diagnostics_tool;
mod edit_files_tool;
mod fetch_tool;
mod find_replace_file_tool;
mod list_directory_tool;
@@ -37,7 +35,6 @@ use crate::create_directory_tool::CreateDirectoryTool;
use crate::create_file_tool::CreateFileTool;
use crate::delete_path_tool::DeletePathTool;
use crate::diagnostics_tool::DiagnosticsTool;
use crate::edit_files_tool::EditFilesTool;
use crate::fetch_tool::FetchTool;
use crate::find_replace_file_tool::FindReplaceFileTool;
use crate::list_directory_tool::ListDirectoryTool;
@@ -51,7 +48,6 @@ use crate::thinking_tool::ThinkingTool;
pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
assistant_tool::init(cx);
crate::edit_files_tool::log::init(cx);
let registry = ToolRegistry::global(cx);
registry.register_tool(BashTool);
@@ -64,7 +60,6 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
registry.register_tool(SymbolInfoTool);
registry.register_tool(MovePathTool);
registry.register_tool(DiagnosticsTool);
registry.register_tool(EditFilesTool);
registry.register_tool(ListDirectoryTool);
registry.register_tool(NowTool);
registry.register_tool(OpenTool);

View File

@@ -1,6 +1,8 @@
use crate::schema::json_schema_for;
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use futures::io::BufReader;
use futures::{AsyncBufReadExt, AsyncReadExt};
use gpui::{App, Entity, Task};
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
use project::Project;
@@ -14,7 +16,7 @@ use util::markdown::MarkdownString;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct BashToolInput {
/// The bash command to execute as a one-liner.
/// The bash one-liner command to execute.
command: String,
/// Working directory for the command. This must be one of the root directories of the project.
cd: String,
@@ -125,29 +127,90 @@ impl Tool for BashTool {
// Add 2>&1 to merge stderr into stdout for proper interleaving.
let command = format!("({}) 2>&1", input.command);
let output = new_smol_command("bash")
let mut cmd = new_smol_command("bash")
.arg("-c")
.arg(&command)
.current_dir(working_dir)
.output()
.await
.stdout(std::process::Stdio::piped())
.spawn()
.context("Failed to execute bash command")?;
let output_string = String::from_utf8_lossy(&output.stdout).to_string();
// Capture stdout with a limit
let stdout = cmd.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
if output.status.success() {
const MESSAGE_1: &str = "Command output too long. The first ";
const MESSAGE_2: &str = " bytes:\n\n";
const ERR_MESSAGE_1: &str = "Command failed with exit code ";
const ERR_MESSAGE_2: &str = "\n\n";
const STDOUT_LIMIT: usize = 8192;
const LIMIT: usize = STDOUT_LIMIT
- (MESSAGE_1.len()
+ (STDOUT_LIMIT.ilog10() as usize + 1) // byte count
+ MESSAGE_2.len()
+ ERR_MESSAGE_1.len()
+ 3 // status code
+ ERR_MESSAGE_2.len());
// Read one more byte to determine whether the output was truncated
let mut buffer = vec![0; LIMIT + 1];
let bytes_read = reader.read(&mut buffer).await?;
// Repeatedly fill the output reader's buffer without copying it.
loop {
let skipped_bytes = reader.fill_buf().await?;
if skipped_bytes.is_empty() {
break;
}
let skipped_bytes_len = skipped_bytes.len();
reader.consume_unpin(skipped_bytes_len);
}
let output_bytes = &buffer[..bytes_read];
// Let the process continue running
let status = cmd.status().await.context("Failed to get command status")?;
let output_string = if bytes_read > LIMIT {
// Valid to find `\n` in UTF-8 since 0-127 ASCII characters are not used in
// multi-byte characters.
let last_line_ix = output_bytes.iter().rposition(|b| *b == b'\n');
let output_string = String::from_utf8_lossy(
&output_bytes[..last_line_ix.unwrap_or(output_bytes.len())],
);
format!(
"{}{}{}{}",
MESSAGE_1,
output_string.len(),
MESSAGE_2,
output_string
)
} else {
String::from_utf8_lossy(&output_bytes).into()
};
let output_with_status = if status.success() {
if output_string.is_empty() {
Ok("Command executed successfully.".to_string())
"Command executed successfully.".to_string()
} else {
Ok(output_string)
output_string.to_string()
}
} else {
Ok(format!(
"Command failed with exit code {}\n{}",
output.status.code().unwrap_or(-1),
&output_string
))
}
format!(
"{}{}{}{}",
ERR_MESSAGE_1,
status.code().unwrap_or(-1),
ERR_MESSAGE_2,
output_string,
)
};
debug_assert!(output_with_status.len() <= STDOUT_LIMIT);
Ok(output_with_status)
})
}
}

View File

@@ -31,19 +31,19 @@ pub struct BatchToolInput {
/// {
/// "invocations": [
/// {
/// "name": "read-file",
/// "name": "read_file",
/// "input": {
/// "path": "src/main.rs"
/// }
/// },
/// {
/// "name": "list-directory",
/// "name": "list_directory",
/// "input": {
/// "path": "src/lib"
/// }
/// },
/// {
/// "name": "regex-search",
/// "name": "regex_search",
/// "input": {
/// "regex": "fn run\\("
/// }
@@ -61,7 +61,7 @@ pub struct BatchToolInput {
/// {
/// "invocations": [
/// {
/// "name": "find-replace-file",
/// "name": "find_replace_file",
/// "input": {
/// "path": "src/config.rs",
/// "display_description": "Update default timeout value",
@@ -70,7 +70,7 @@ pub struct BatchToolInput {
/// }
/// },
/// {
/// "name": "find-replace-file",
/// "name": "find_replace_file",
/// "input": {
/// "path": "src/config.rs",
/// "display_description": "Update API endpoint URL",
@@ -91,13 +91,13 @@ pub struct BatchToolInput {
/// {
/// "invocations": [
/// {
/// "name": "regex-search",
/// "name": "regex_search",
/// "input": {
/// "regex": "impl Database"
/// }
/// },
/// {
/// "name": "path-search",
/// "name": "path_search",
/// "input": {
/// "glob": "**/*test*.rs"
/// }
@@ -115,7 +115,7 @@ pub struct BatchToolInput {
/// {
/// "invocations": [
/// {
/// "name": "find-replace-file",
/// "name": "find_replace_file",
/// "input": {
/// "path": "src/models/user.rs",
/// "display_description": "Add email field to User struct",
@@ -124,7 +124,7 @@ pub struct BatchToolInput {
/// }
/// },
/// {
/// "name": "find-replace-file",
/// "name": "find_replace_file",
/// "input": {
/// "path": "src/db/queries.rs",
/// "display_description": "Update user insertion query",

View File

@@ -1,88 +0,0 @@
use project::DocumentSymbol;
use regex::Regex;
#[derive(Debug, Clone)]
pub struct Entry {
pub name: String,
pub kind: lsp::SymbolKind,
pub depth: u32,
pub start_line: usize,
pub end_line: usize,
}
/// An iterator that filters document symbols based on a regex pattern.
/// This iterator recursively traverses the document symbol tree, incrementing depth for child symbols.
#[derive(Debug, Clone)]
pub struct CodeSymbolIterator<'a> {
symbols: &'a [DocumentSymbol],
regex: Option<Regex>,
// Stack of (symbol, depth) pairs to process
pending_symbols: Vec<(&'a DocumentSymbol, u32)>,
current_index: usize,
current_depth: u32,
}
impl<'a> CodeSymbolIterator<'a> {
pub fn new(symbols: &'a [DocumentSymbol], regex: Option<Regex>) -> Self {
Self {
symbols,
regex,
pending_symbols: Vec::new(),
current_index: 0,
current_depth: 0,
}
}
}
impl Iterator for CodeSymbolIterator<'_> {
type Item = Entry;
fn next(&mut self) -> Option<Self::Item> {
if let Some((symbol, depth)) = self.pending_symbols.pop() {
for child in symbol.children.iter().rev() {
self.pending_symbols.push((child, depth + 1));
}
return Some(Entry {
name: symbol.name.clone(),
kind: symbol.kind,
depth,
start_line: symbol.range.start.0.row as usize,
end_line: symbol.range.end.0.row as usize,
});
}
while self.current_index < self.symbols.len() {
let regex = self.regex.as_ref();
let symbol = &self.symbols[self.current_index];
self.current_index += 1;
if regex.is_none_or(|regex| regex.is_match(&symbol.name)) {
// Push in reverse order to maintain traversal order
for child in symbol.children.iter().rev() {
self.pending_symbols.push((child, self.current_depth + 1));
}
return Some(Entry {
name: symbol.name.clone(),
kind: symbol.kind,
depth: self.current_depth,
start_line: symbol.range.start.0.row as usize,
end_line: symbol.range.end.0.row as usize,
});
} else {
// Even if parent doesn't match, push children to check them later
for child in symbol.children.iter().rev() {
self.pending_symbols.push((child, self.current_depth + 1));
}
// Check if any pending children match our criteria
if let Some(result) = self.next() {
return Some(result);
}
}
}
None
}
}

View File

@@ -1,24 +1,21 @@
use std::fmt::{self, Write};
use std::fmt::Write;
use std::path::PathBuf;
use std::sync::Arc;
use crate::schema::json_schema_for;
use anyhow::{Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use collections::IndexMap;
use gpui::{App, AsyncApp, Entity, Task};
use language::{CodeLabel, Language, LanguageRegistry};
use language::{OutlineItem, ParseStatus, Point};
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
use lsp::SymbolKind;
use project::{DocumentSymbol, Project, Symbol};
use project::{Project, Symbol};
use regex::{Regex, RegexBuilder};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use ui::IconName;
use util::markdown::MarkdownString;
use crate::code_symbol_iter::{CodeSymbolIterator, Entry};
use crate::schema::json_schema_for;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CodeSymbolsInput {
/// The relative path of the source code file to read and get the symbols for.
@@ -156,7 +153,7 @@ impl Tool for CodeSymbolsTool {
}
}
async fn file_outline(
pub async fn file_outline(
project: Entity<Project>,
path: String,
action_log: Entity<ActionLog>,
@@ -180,24 +177,28 @@ async fn file_outline(
action_log.buffer_read(buffer.clone(), cx);
})?;
let symbols = project
.update(cx, |project, cx| project.document_symbols(&buffer, cx))?
.await?;
// Wait until the buffer has been fully parsed, so that we can read its outline.
let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?;
while parse_status
.recv()
.await
.map_or(false, |status| status != ParseStatus::Idle)
{}
if symbols.is_empty() {
return Err(
if buffer.read_with(cx, |buffer, _| buffer.snapshot().is_empty())? {
anyhow!("This file is empty.")
} else {
anyhow!("No outline information available for this file.")
},
);
}
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
let Some(outline) = snapshot.outline(None) else {
return Err(anyhow!("No outline information available for this file."));
};
let language = buffer.read_with(cx, |buffer, _| buffer.language().cloned())?;
let language_registry = project.read_with(cx, |project, _| project.languages().clone())?;
render_outline(&symbols, language, language_registry, regex, offset).await
render_outline(
outline
.items
.into_iter()
.map(|item| item.to_point(&snapshot)),
regex,
offset,
)
.await
}
async fn project_symbols(
@@ -292,61 +293,27 @@ async fn project_symbols(
}
async fn render_outline(
symbols: &[DocumentSymbol],
language: Option<Arc<Language>>,
registry: Arc<LanguageRegistry>,
items: impl IntoIterator<Item = OutlineItem<Point>>,
regex: Option<Regex>,
offset: u32,
) -> Result<String> {
const RESULTS_PER_PAGE_USIZE: usize = RESULTS_PER_PAGE as usize;
let entries = CodeSymbolIterator::new(symbols, regex.clone())
.skip(offset as usize)
// Take 1 more than RESULTS_PER_PAGE so we can tell if there are more results.
.take(RESULTS_PER_PAGE_USIZE.saturating_add(1))
.collect::<Vec<Entry>>();
let has_more = entries.len() > RESULTS_PER_PAGE_USIZE;
// Get language-specific labels, if available
let labels = match &language {
Some(lang) => {
let entries_for_labels: Vec<(String, SymbolKind)> = entries
.iter()
.take(RESULTS_PER_PAGE_USIZE)
.map(|entry| (entry.name.clone(), entry.kind))
.collect();
let mut items = items.into_iter().skip(offset as usize);
let lang_name = lang.name();
if let Some(lsp_adapter) = registry.lsp_adapters(&lang_name).first().cloned() {
lsp_adapter
.labels_for_symbols(&entries_for_labels, lang)
.await
.ok()
} else {
None
}
}
None => None,
};
let entries = items
.by_ref()
.filter(|item| {
regex
.as_ref()
.is_none_or(|regex| regex.is_match(&item.text))
})
.take(RESULTS_PER_PAGE_USIZE)
.collect::<Vec<_>>();
let has_more = items.next().is_some();
let mut output = String::new();
let entries_rendered = match &labels {
Some(label_list) => render_entries(
&mut output,
entries
.into_iter()
.take(RESULTS_PER_PAGE_USIZE)
.zip(label_list.iter())
.map(|(entry, label)| (entry, label.as_ref())),
),
None => render_entries(
&mut output,
entries
.into_iter()
.take(RESULTS_PER_PAGE_USIZE)
.map(|entry| (entry, None)),
),
};
let entries_rendered = render_entries(&mut output, entries);
// Calculate pagination information
let page_start = offset + 1;
@@ -372,31 +339,19 @@ async fn render_outline(
Ok(output)
}
fn render_entries<'a>(
output: &mut String,
entries: impl IntoIterator<Item = (Entry, Option<&'a CodeLabel>)>,
) -> u32 {
fn render_entries(output: &mut String, items: impl IntoIterator<Item = OutlineItem<Point>>) -> u32 {
let mut entries_rendered = 0;
for (entry, label) in entries {
for item in items {
// Indent based on depth ("" for level 0, " " for level 1, etc.)
for _ in 0..entry.depth {
output.push_str(" ");
}
match label {
Some(label) => {
output.push_str(label.text());
}
None => {
write_symbol_kind(output, entry.kind).ok();
output.push_str(&entry.name);
}
for _ in 0..item.depth {
output.push(' ');
}
output.push_str(&item.text);
// Add position information - convert to 1-based line numbers for display
let start_line = entry.start_line + 1;
let end_line = entry.end_line + 1;
let start_line = item.range.start.row + 1;
let end_line = item.range.end.row + 1;
if start_line == end_line {
writeln!(output, " [L{}]", start_line).ok();
@@ -408,38 +363,3 @@ fn render_entries<'a>(
entries_rendered
}
// We may not have a language server adapter to have language-specific
// ways to translate SymbolKnd into a string. In that situation,
// fall back on some reasonable default strings to render.
fn write_symbol_kind(buf: &mut String, kind: SymbolKind) -> Result<(), fmt::Error> {
match kind {
SymbolKind::FILE => write!(buf, "file "),
SymbolKind::MODULE => write!(buf, "module "),
SymbolKind::NAMESPACE => write!(buf, "namespace "),
SymbolKind::PACKAGE => write!(buf, "package "),
SymbolKind::CLASS => write!(buf, "class "),
SymbolKind::METHOD => write!(buf, "method "),
SymbolKind::PROPERTY => write!(buf, "property "),
SymbolKind::FIELD => write!(buf, "field "),
SymbolKind::CONSTRUCTOR => write!(buf, "constructor "),
SymbolKind::ENUM => write!(buf, "enum "),
SymbolKind::INTERFACE => write!(buf, "interface "),
SymbolKind::FUNCTION => write!(buf, "function "),
SymbolKind::VARIABLE => write!(buf, "variable "),
SymbolKind::CONSTANT => write!(buf, "constant "),
SymbolKind::STRING => write!(buf, "string "),
SymbolKind::NUMBER => write!(buf, "number "),
SymbolKind::BOOLEAN => write!(buf, "boolean "),
SymbolKind::ARRAY => write!(buf, "array "),
SymbolKind::OBJECT => write!(buf, "object "),
SymbolKind::KEY => write!(buf, "key "),
SymbolKind::NULL => write!(buf, "null "),
SymbolKind::ENUM_MEMBER => write!(buf, "enum member "),
SymbolKind::STRUCT => write!(buf, "struct "),
SymbolKind::EVENT => write!(buf, "event "),
SymbolKind::OPERATOR => write!(buf, "operator "),
SymbolKind::TYPE_PARAMETER => write!(buf, "type parameter "),
_ => Ok(()),
}
}

View File

@@ -1,559 +0,0 @@
mod edit_action;
pub mod log;
use crate::replace::{replace_exact, replace_with_flexible_indent};
use crate::schema::json_schema_for;
use anyhow::{Context, Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use collections::HashSet;
use edit_action::{EditAction, EditActionParser, edit_model_prompt};
use futures::{SinkExt, StreamExt, channel::mpsc};
use gpui::{App, AppContext, AsyncApp, Entity, Task};
use language_model::LanguageModelToolSchemaFormat;
use language_model::{
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role,
};
use log::{EditToolLog, EditToolRequestId};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use std::sync::Arc;
use ui::IconName;
use util::ResultExt;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct EditFilesToolInput {
/// High-level edit instructions. These will be interpreted by a smaller
/// model, so explain the changes you want that model to make and which
/// file paths need changing. The description should be concise and clear.
///
/// WARNING: When specifying which file paths need changing, you MUST
/// start each path with one of the project's root directories.
///
/// WARNING: NEVER include code blocks or snippets in edit instructions.
/// Only provide natural language descriptions of the changes needed! The tool will
/// reject any instructions that contain code blocks or snippets.
///
/// The following examples assume we have two root directories in the project:
/// - root-1
/// - root-2
///
/// <example>
/// If you want to introduce a new quit function to kill the process, your
/// instructions should be: "Add a new `quit` function to
/// `root-1/src/main.rs` to kill the process".
///
/// Notice how the file path starts with root-1. Without that, the path
/// would be ambiguous and the call would fail!
/// </example>
///
/// <example>
/// If you want to change documentation to always start with a capital
/// letter, your instructions should be: "In `root-2/db.js`,
/// `root-2/inMemory.js` and `root-2/sql.js`, change all the documentation
/// to start with a capital letter".
///
/// Notice how we never specify code snippets in the instructions!
/// </example>
pub edit_instructions: String,
/// A user-friendly description of what changes are being made.
/// This will be shown to the user in the UI to describe the edit operation. The screen real estate for this UI will be extremely
/// constrained, so make the description extremely terse.
///
/// <example>
/// For fixing a broken authentication system:
/// "Fix auth bug in login flow"
/// </example>
///
/// <example>
/// For adding unit tests to a module:
/// "Add tests for user profile logic"
/// </example>
pub display_description: String,
}
pub struct EditFilesTool;
impl Tool for EditFilesTool {
fn name(&self) -> String {
"edit_files".into()
}
fn needs_confirmation(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./edit_files_tool/description.md").into()
}
fn icon(&self) -> IconName {
IconName::Pencil
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value {
json_schema_for::<EditFilesToolInput>(format)
}
fn ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::<EditFilesToolInput>(input.clone()) {
Ok(input) => input.display_description,
Err(_) => "Edit files".to_string(),
}
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
let input = match serde_json::from_value::<EditFilesToolInput>(input) {
Ok(input) => input,
Err(err) => return Task::ready(Err(anyhow!(err))),
};
match EditToolLog::try_global(cx) {
Some(log) => {
let req_id = log.update(cx, |log, cx| {
log.new_request(input.edit_instructions.clone(), cx)
});
let task = EditToolRequest::new(
input,
messages,
project,
action_log,
Some((log.clone(), req_id)),
cx,
);
cx.spawn(async move |cx| {
let result = task.await;
let str_result = match &result {
Ok(out) => Ok(out.clone()),
Err(err) => Err(err.to_string()),
};
log.update(cx, |log, cx| log.set_tool_output(req_id, str_result, cx))
.log_err();
result
})
}
None => EditToolRequest::new(input, messages, project, action_log, None, cx),
}
}
}
struct EditToolRequest {
parser: EditActionParser,
editor_response: EditorResponse,
project: Entity<Project>,
action_log: Entity<ActionLog>,
tool_log: Option<(Entity<EditToolLog>, EditToolRequestId)>,
}
enum EditorResponse {
/// The editor model hasn't produced any actions yet.
/// If we don't have any by the end, we'll return its message to the architect model.
Message(String),
/// The editor model produced at least one action.
Actions {
applied: Vec<AppliedAction>,
search_errors: Vec<SearchError>,
},
}
struct AppliedAction {
source: String,
buffer: Entity<language::Buffer>,
}
#[derive(Debug)]
enum DiffResult {
Diff(language::Diff),
SearchError(SearchError),
}
#[derive(Debug)]
enum SearchError {
NoMatch {
file_path: String,
search: String,
},
EmptyBuffer {
file_path: String,
search: String,
exists: bool,
},
}
impl EditToolRequest {
fn new(
input: EditFilesToolInput,
messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
action_log: Entity<ActionLog>,
tool_log: Option<(Entity<EditToolLog>, EditToolRequestId)>,
cx: &mut App,
) -> Task<Result<String>> {
let model_registry = LanguageModelRegistry::read_global(cx);
let Some(model) = model_registry.editor_model() else {
return Task::ready(Err(anyhow!("No editor model configured")));
};
let mut messages = messages.to_vec();
// Remove the last tool use (this run) to prevent an invalid request
'outer: for message in messages.iter_mut().rev() {
for (index, content) in message.content.iter().enumerate().rev() {
match content {
MessageContent::ToolUse(_) => {
message.content.remove(index);
break 'outer;
}
MessageContent::ToolResult(_) => {
// If we find any tool results before a tool use, the request is already valid
break 'outer;
}
MessageContent::Text(_) | MessageContent::Image(_) => {}
}
}
}
messages.push(LanguageModelRequestMessage {
role: Role::User,
content: vec![edit_model_prompt().into(), input.edit_instructions.into()],
cache: false,
});
cx.spawn(async move |cx| {
let llm_request = LanguageModelRequest {
messages,
tools: vec![],
stop: vec![],
temperature: Some(0.0),
};
let (mut tx, mut rx) = mpsc::channel::<String>(32);
let stream = model.stream_completion_text(llm_request, &cx);
let reader_task = cx.background_spawn(async move {
let mut chunks = stream.await?;
while let Some(chunk) = chunks.stream.next().await {
if let Some(chunk) = chunk.log_err() {
// we don't process here because the API fails
// if we take too long between reads
tx.send(chunk).await?
}
}
tx.close().await?;
anyhow::Ok(())
});
let mut request = Self {
parser: EditActionParser::new(),
editor_response: EditorResponse::Message(String::with_capacity(256)),
action_log,
project,
tool_log,
};
while let Some(chunk) = rx.next().await {
request.process_response_chunk(&chunk, cx).await?;
}
reader_task.await?;
request.finalize(cx).await
})
}
async fn process_response_chunk(&mut self, chunk: &str, cx: &mut AsyncApp) -> Result<()> {
let new_actions = self.parser.parse_chunk(chunk);
if let EditorResponse::Message(ref mut message) = self.editor_response {
if new_actions.is_empty() {
message.push_str(chunk);
}
}
if let Some((ref log, req_id)) = self.tool_log {
log.update(cx, |log, cx| {
log.push_editor_response_chunk(req_id, chunk, &new_actions, cx)
})
.log_err();
}
for action in new_actions {
self.apply_action(action, cx).await?;
}
Ok(())
}
async fn apply_action(
&mut self,
(action, source): (EditAction, String),
cx: &mut AsyncApp,
) -> Result<()> {
let project_path = self.project.read_with(cx, |project, cx| {
project
.find_project_path(action.file_path(), cx)
.context("Path not found in project")
})??;
let buffer = self
.project
.update(cx, |project, cx| project.open_buffer(project_path, cx))?
.await?;
let result = match action {
EditAction::Replace {
old,
new,
file_path,
} => {
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
cx.background_executor()
.spawn(Self::replace_diff(old, new, file_path, snapshot))
.await
}
EditAction::Write { content, .. } => Ok(DiffResult::Diff(
buffer
.read_with(cx, |buffer, cx| buffer.diff(content, cx))?
.await,
)),
}?;
match result {
DiffResult::SearchError(error) => {
self.push_search_error(error);
}
DiffResult::Diff(diff) => {
cx.update(|cx| {
self.action_log
.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx));
buffer.update(cx, |buffer, cx| {
buffer.finalize_last_transaction();
buffer.apply_diff(diff, cx);
buffer.finalize_last_transaction();
});
self.action_log
.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
})?;
self.push_applied_action(AppliedAction { source, buffer });
}
}
anyhow::Ok(())
}
fn push_search_error(&mut self, error: SearchError) {
match &mut self.editor_response {
EditorResponse::Message(_) => {
self.editor_response = EditorResponse::Actions {
applied: Vec::new(),
search_errors: vec![error],
};
}
EditorResponse::Actions { search_errors, .. } => {
search_errors.push(error);
}
}
}
fn push_applied_action(&mut self, action: AppliedAction) {
match &mut self.editor_response {
EditorResponse::Message(_) => {
self.editor_response = EditorResponse::Actions {
applied: vec![action],
search_errors: Vec::new(),
};
}
EditorResponse::Actions { applied, .. } => {
applied.push(action);
}
}
}
async fn replace_diff(
old: String,
new: String,
file_path: std::path::PathBuf,
snapshot: language::BufferSnapshot,
) -> Result<DiffResult> {
if snapshot.is_empty() {
let exists = snapshot
.file()
.map_or(false, |file| file.disk_state().exists());
let error = SearchError::EmptyBuffer {
file_path: file_path.display().to_string(),
exists,
search: old,
};
return Ok(DiffResult::SearchError(error));
}
let replace_result =
// Try to match exactly
replace_exact(&old, &new, &snapshot)
.await
// If that fails, try being flexible about indentation
.or_else(|| replace_with_flexible_indent(&old, &new, &snapshot));
let Some(diff) = replace_result else {
let error = SearchError::NoMatch {
search: old,
file_path: file_path.display().to_string(),
};
return Ok(DiffResult::SearchError(error));
};
Ok(DiffResult::Diff(diff))
}
async fn finalize(self, cx: &mut AsyncApp) -> Result<String> {
match self.editor_response {
EditorResponse::Message(message) => Err(anyhow!(
"No edits were applied! You might need to provide more context.\n\n{}",
message
)),
EditorResponse::Actions {
applied,
search_errors,
} => {
let mut output = String::with_capacity(1024);
let parse_errors = self.parser.errors();
let has_errors = !search_errors.is_empty() || !parse_errors.is_empty();
if has_errors {
let error_count = search_errors.len() + parse_errors.len();
if applied.is_empty() {
writeln!(
&mut output,
"{} errors occurred! No edits were applied.",
error_count,
)?;
} else {
writeln!(
&mut output,
"{} errors occurred, but {} edits were correctly applied.",
error_count,
applied.len(),
)?;
writeln!(
&mut output,
"# {} SEARCH/REPLACE block(s) applied:\n\nDo not re-send these since they are already applied!\n",
applied.len()
)?;
}
} else {
write!(
&mut output,
"Successfully applied! Here's a list of applied edits:"
)?;
}
let mut changed_buffers = HashSet::default();
for action in applied {
changed_buffers.insert(action.buffer.clone());
write!(&mut output, "\n\n{}", action.source)?;
}
for buffer in &changed_buffers {
self.project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
.await?;
}
if !search_errors.is_empty() {
writeln!(
&mut output,
"\n\n## {} SEARCH/REPLACE block(s) failed to match:\n",
search_errors.len()
)?;
for error in search_errors {
match error {
SearchError::NoMatch { file_path, search } => {
writeln!(
&mut output,
"### No exact match in: `{}`\n```\n{}\n```\n",
file_path, search,
)?;
}
SearchError::EmptyBuffer {
file_path,
exists: true,
search,
} => {
writeln!(
&mut output,
"### No match because `{}` is empty:\n```\n{}\n```\n",
file_path, search,
)?;
}
SearchError::EmptyBuffer {
file_path,
exists: false,
search,
} => {
writeln!(
&mut output,
"### No match because `{}` does not exist:\n```\n{}\n```\n",
file_path, search,
)?;
}
}
}
write!(
&mut output,
"The SEARCH section must exactly match an existing block of lines including all white \
space, comments, indentation, docstrings, etc."
)?;
}
if !parse_errors.is_empty() {
writeln!(
&mut output,
"\n\n## {} SEARCH/REPLACE blocks failed to parse:",
parse_errors.len()
)?;
for error in parse_errors {
writeln!(&mut output, "- {}", error)?;
}
}
if has_errors {
writeln!(
&mut output,
"\n\nYou can fix errors by running the tool again. You can include instructions, \
but errors are part of the conversation so you don't need to repeat them.",
)?;
Err(anyhow!(output))
} else {
Ok(output)
}
}
}
}
}

View File

@@ -1,11 +0,0 @@
Edit files in the current project by specifying instructions in natural language.
IMPORTANT NOTE: If there is a find-replace tool, use that instead of this tool! This tool is only to be used as a fallback in case that tool is unavailable. Always prefer that tool if it is available.
When using this tool, you should suggest one coherent edit that can be made to the codebase.
When the set of edits you want to make is large or complex, feel free to invoke this tool multiple times, each time focusing on a specific change you wanna make.
You should use this tool when you want to edit a subset of a file's contents, but not the entire file. You should not use this tool when you want to replace the entire contents of a file with completely different contents, and you absolutely must never use this tool to create new files from scratch. If you ever consider using this tool to create a new file from scratch, for any reason, instead you must reconsider and choose a different approach.
DO NOT call this tool until the code to be edited appears in the conversation! You must use the `read-files` tool or ask the user to add it to context first.

View File

@@ -1,967 +0,0 @@
use std::{
mem::take,
ops::Range,
path::{Path, PathBuf},
};
use util::ResultExt;
/// Represents an edit action to be performed on a file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditAction {
/// Replace specific content in a file with new content
Replace {
file_path: PathBuf,
old: String,
new: String,
},
/// Write content to a file (create or overwrite)
Write { file_path: PathBuf, content: String },
}
impl EditAction {
pub fn file_path(&self) -> &Path {
match self {
EditAction::Replace { file_path, .. } => file_path,
EditAction::Write { file_path, .. } => file_path,
}
}
}
/// Parses edit actions from an LLM response.
/// See system.md for more details on the format.
#[derive(Debug)]
pub struct EditActionParser {
state: State,
line: usize,
column: usize,
marker_ix: usize,
action_source: Vec<u8>,
fence_start_offset: usize,
block_range: Range<usize>,
old_range: Range<usize>,
new_range: Range<usize>,
errors: Vec<ParseError>,
}
#[derive(Debug, PartialEq, Eq)]
enum State {
/// Anywhere outside an action
Default,
/// After opening ```, in optional language tag
OpenFence,
/// In SEARCH marker
SearchMarker,
/// In search block or divider
SearchBlock,
/// In replace block or REPLACE marker
ReplaceBlock,
/// In closing ```
CloseFence,
}
/// used to avoid having source code that looks like git-conflict markers
macro_rules! marker_sym {
($char:expr) => {
concat!($char, $char, $char, $char, $char, $char, $char)
};
}
const SEARCH_MARKER: &str = concat!(marker_sym!('<'), " SEARCH");
const DIVIDER: &str = marker_sym!('=');
const NL_DIVIDER: &str = concat!("\n", marker_sym!('='));
const REPLACE_MARKER: &str = concat!(marker_sym!('>'), " REPLACE");
const NL_REPLACE_MARKER: &str = concat!("\n", marker_sym!('>'), " REPLACE");
const FENCE: &str = "```";
impl EditActionParser {
/// Creates a new `EditActionParser`
pub fn new() -> Self {
Self {
state: State::Default,
line: 1,
column: 0,
action_source: Vec::new(),
fence_start_offset: 0,
marker_ix: 0,
block_range: Range::default(),
old_range: Range::default(),
new_range: Range::default(),
errors: Vec::new(),
}
}
/// Processes a chunk of input text and returns any completed edit actions.
///
/// This method can be called repeatedly with fragments of input. The parser
/// maintains its state between calls, allowing you to process streaming input
/// as it becomes available. Actions are only inserted once they are fully parsed.
///
/// If a block fails to parse, it will simply be skipped and an error will be recorded.
/// All errors can be accessed through the `EditActionsParser::errors` method.
pub fn parse_chunk(&mut self, input: &str) -> Vec<(EditAction, String)> {
use State::*;
let mut actions = Vec::new();
for byte in input.bytes() {
// Update line and column tracking
if byte == b'\n' {
self.line += 1;
self.column = 0;
} else {
self.column += 1;
}
let action_offset = self.action_source.len();
match &self.state {
Default => match self.match_marker(byte, FENCE, false) {
MarkerMatch::Complete => {
self.fence_start_offset = action_offset + 1 - FENCE.len();
self.to_state(OpenFence);
}
MarkerMatch::Partial => {}
MarkerMatch::None => {
if self.marker_ix > 0 {
self.marker_ix = 0;
} else if self.action_source.ends_with(b"\n") {
self.action_source.clear();
}
}
},
OpenFence => {
// skip language tag
if byte == b'\n' {
self.to_state(SearchMarker);
}
}
SearchMarker => {
if self.expect_marker(byte, SEARCH_MARKER, true) {
self.to_state(SearchBlock);
}
}
SearchBlock => {
if self.extend_block_range(byte, DIVIDER, NL_DIVIDER) {
self.old_range = take(&mut self.block_range);
self.to_state(ReplaceBlock);
}
}
ReplaceBlock => {
if self.extend_block_range(byte, REPLACE_MARKER, NL_REPLACE_MARKER) {
self.new_range = take(&mut self.block_range);
self.to_state(CloseFence);
}
}
CloseFence => {
if self.expect_marker(byte, FENCE, false) {
self.action_source.push(byte);
if let Some(action) = self.action() {
actions.push(action);
}
self.errors();
self.reset();
continue;
}
}
};
self.action_source.push(byte);
}
actions
}
/// Returns a reference to the errors encountered during parsing.
pub fn errors(&self) -> &[ParseError] {
&self.errors
}
fn action(&mut self) -> Option<(EditAction, String)> {
let old_range = take(&mut self.old_range);
let new_range = take(&mut self.new_range);
let action_source = take(&mut self.action_source);
let action_source = String::from_utf8(action_source).log_err()?;
let mut file_path_bytes = action_source[..self.fence_start_offset].to_owned();
if file_path_bytes.ends_with("\n") {
file_path_bytes.pop();
if file_path_bytes.ends_with("\r") {
file_path_bytes.pop();
}
}
let file_path = PathBuf::from(file_path_bytes);
if old_range.is_empty() {
return Some((
EditAction::Write {
file_path,
content: action_source[new_range].to_owned(),
},
action_source,
));
}
let old = action_source[old_range].to_owned();
let new = action_source[new_range].to_owned();
let action = EditAction::Replace {
file_path,
old,
new,
};
Some((action, action_source))
}
fn to_state(&mut self, state: State) {
self.state = state;
self.marker_ix = 0;
}
fn reset(&mut self) {
self.action_source.clear();
self.block_range = Range::default();
self.old_range = Range::default();
self.new_range = Range::default();
self.fence_start_offset = 0;
self.marker_ix = 0;
self.to_state(State::Default);
}
fn expect_marker(&mut self, byte: u8, marker: &'static str, trailing_newline: bool) -> bool {
match self.match_marker(byte, marker, trailing_newline) {
MarkerMatch::Complete => true,
MarkerMatch::Partial => false,
MarkerMatch::None => {
self.errors.push(ParseError {
line: self.line,
column: self.column,
expected: marker,
found: byte,
});
self.reset();
false
}
}
}
fn extend_block_range(&mut self, byte: u8, marker: &str, nl_marker: &str) -> bool {
let marker = if self.block_range.is_empty() {
// do not require another newline if block is empty
marker
} else {
nl_marker
};
let offset = self.action_source.len();
match self.match_marker(byte, marker, true) {
MarkerMatch::Complete => {
if self.action_source[self.block_range.clone()].ends_with(b"\r") {
self.block_range.end -= 1;
}
true
}
MarkerMatch::Partial => false,
MarkerMatch::None => {
if self.marker_ix > 0 {
self.marker_ix = 0;
self.block_range.end = offset;
// The beginning of marker might match current byte
match self.match_marker(byte, marker, true) {
MarkerMatch::Complete => return true,
MarkerMatch::Partial => return false,
MarkerMatch::None => { /* no match, keep collecting */ }
}
}
if self.block_range.is_empty() {
self.block_range.start = offset;
}
self.block_range.end = offset + 1;
false
}
}
}
fn match_marker(&mut self, byte: u8, marker: &str, trailing_newline: bool) -> MarkerMatch {
if trailing_newline && self.marker_ix >= marker.len() {
if byte == b'\n' {
MarkerMatch::Complete
} else if byte == b'\r' {
MarkerMatch::Partial
} else {
MarkerMatch::None
}
} else if byte == marker.as_bytes()[self.marker_ix] {
self.marker_ix += 1;
if self.marker_ix < marker.len() || trailing_newline {
MarkerMatch::Partial
} else {
MarkerMatch::Complete
}
} else {
MarkerMatch::None
}
}
}
#[derive(Debug)]
enum MarkerMatch {
None,
Partial,
Complete,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ParseError {
line: usize,
column: usize,
expected: &'static str,
found: u8,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"input:{}:{}: Expected marker {:?}, found {:?}",
self.line, self.column, self.expected, self.found as char
)
}
}
pub fn edit_model_prompt() -> String {
include_str!("edit_prompt.md")
.to_string()
.replace("{{SEARCH_MARKER}}", SEARCH_MARKER)
.replace("{{DIVIDER}}", DIVIDER)
.replace("{{REPLACE_MARKER}}", REPLACE_MARKER)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::prelude::*;
use util::line_endings;
const WRONG_MARKER: &str = concat!(marker_sym!('<'), " WRONG_MARKER");
#[test]
fn test_simple_edit_action() {
// Construct test input using format with multiline string literals
let input = format!(
r#"src/main.rs
```
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn original() {}".to_string(),
new: "fn replacement() {}".to_string(),
}
);
}
#[test]
fn test_with_language_tag() {
// Construct test input using format with multiline string literals
let input = format!(
r#"src/main.rs
```rust
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn original() {}".to_string(),
new: "fn replacement() {}".to_string(),
}
);
}
#[test]
fn test_with_surrounding_text() {
// Construct test input using format with multiline string literals
let input = format!(
r#"Here's a modification I'd like to make to the file:
src/main.rs
```rust
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
```
This change makes the function better.
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn original() {}".to_string(),
new: "fn replacement() {}".to_string(),
}
);
}
#[test]
fn test_multiple_edit_actions() {
// Construct test input using format with multiline string literals
let input = format!(
r#"First change:
src/main.rs
```
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
```
Second change:
src/utils.rs
```rust
{}
fn old_util() -> bool {{ false }}
{}
fn new_util() -> bool {{ true }}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER, SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 2);
let (action, _) = &actions[0];
assert_eq!(
action,
&EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn original() {}".to_string(),
new: "fn replacement() {}".to_string(),
}
);
let (action2, _) = &actions[1];
assert_eq!(
action2,
&EditAction::Replace {
file_path: PathBuf::from("src/utils.rs"),
old: "fn old_util() -> bool { false }".to_string(),
new: "fn new_util() -> bool { true }".to_string(),
}
);
}
#[test]
fn test_multiline() {
// Construct test input using format with multiline string literals
let input = format!(
r#"src/main.rs
```rust
{}
fn original() {{
println!("This is the original function");
let x = 42;
if x > 0 {{
println!("Positive number");
}}
}}
{}
fn replacement() {{
println!("This is the replacement function");
let x = 100;
if x > 50 {{
println!("Large number");
}} else {{
println!("Small number");
}}
}}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
let (action, _) = &actions[0];
assert_eq!(
action,
&EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn original() {\n println!(\"This is the original function\");\n let x = 42;\n if x > 0 {\n println!(\"Positive number\");\n }\n}".to_string(),
new: "fn replacement() {\n println!(\"This is the replacement function\");\n let x = 100;\n if x > 50 {\n println!(\"Large number\");\n } else {\n println!(\"Small number\");\n }\n}".to_string(),
}
);
}
#[test]
fn test_write_action() {
// Construct test input using format with multiline string literals
let input = format!(
r#"Create a new main.rs file:
src/main.rs
```rust
{}
{}
fn new_function() {{
println!("This function is being added");
}}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Write {
file_path: PathBuf::from("src/main.rs"),
content: "fn new_function() {\n println!(\"This function is being added\");\n}"
.to_string(),
}
);
}
#[test]
fn test_empty_replace() {
// Construct test input using format with multiline string literals
let input = format!(
r#"src/main.rs
```rust
{}
fn this_will_be_deleted() {{
println!("Deleting this function");
}}
{}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn this_will_be_deleted() {\n println!(\"Deleting this function\");\n}"
.to_string(),
new: "".to_string(),
}
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input.replace("\n", "\r\n"));
assert_no_errors(&parser);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old:
"fn this_will_be_deleted() {\r\n println!(\"Deleting this function\");\r\n}"
.to_string(),
new: "".to_string(),
}
);
}
#[test]
fn test_empty_both() {
// Construct test input using format with multiline string literals
let input = format!(
r#"src/main.rs
```rust
{}
{}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].0,
EditAction::Write {
file_path: PathBuf::from("src/main.rs"),
content: String::new(),
}
);
assert_no_errors(&parser);
}
#[test]
fn test_resumability() {
// Construct test input using format with multiline string literals
let input_part1 = format!("src/main.rs\n```rust\n{}\nfn ori", SEARCH_MARKER);
let input_part2 = format!("ginal() {{}}\n{}\nfn replacement() {{}}", DIVIDER);
let input_part3 = format!("\n{}\n```\n", REPLACE_MARKER);
let mut parser = EditActionParser::new();
let actions1 = parser.parse_chunk(&input_part1);
assert_no_errors(&parser);
assert_eq!(actions1.len(), 0);
let actions2 = parser.parse_chunk(&input_part2);
// No actions should be complete yet
assert_no_errors(&parser);
assert_eq!(actions2.len(), 0);
let actions3 = parser.parse_chunk(&input_part3);
// The third chunk should complete the action
assert_no_errors(&parser);
assert_eq!(actions3.len(), 1);
let (action, _) = &actions3[0];
assert_eq!(
action,
&EditAction::Replace {
file_path: PathBuf::from("src/main.rs"),
old: "fn original() {}".to_string(),
new: "fn replacement() {}".to_string(),
}
);
}
#[test]
fn test_parser_state_preservation() {
let mut parser = EditActionParser::new();
let first_chunk = format!("src/main.rs\n```rust\n{}\n", SEARCH_MARKER);
let actions1 = parser.parse_chunk(&first_chunk);
// Check parser is in the correct state
assert_no_errors(&parser);
assert_eq!(parser.state, State::SearchBlock);
assert_eq!(parser.action_source, first_chunk.as_bytes());
// Continue parsing
let second_chunk = format!("original code\n{}\n", DIVIDER);
let actions2 = parser.parse_chunk(&second_chunk);
assert_no_errors(&parser);
assert_eq!(parser.state, State::ReplaceBlock);
assert_eq!(
&parser.action_source[parser.old_range.clone()],
b"original code"
);
let third_chunk = format!("replacement code\n{}\n```\n", REPLACE_MARKER);
let actions3 = parser.parse_chunk(&third_chunk);
// After complete parsing, state should reset
assert_no_errors(&parser);
assert_eq!(parser.state, State::Default);
assert_eq!(parser.action_source, b"\n");
assert!(parser.old_range.is_empty());
assert!(parser.new_range.is_empty());
assert_eq!(actions1.len(), 0);
assert_eq!(actions2.len(), 0);
assert_eq!(actions3.len(), 1);
}
#[test]
fn test_invalid_search_marker() {
let input = format!(
r#"src/main.rs
```rust
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
```
"#,
WRONG_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
assert_eq!(actions.len(), 0);
assert_eq!(parser.errors().len(), 1);
let error = &parser.errors()[0];
assert_eq!(
error.to_string(),
format!(
"input:3:9: Expected marker \"{}\", found 'W'",
SEARCH_MARKER
)
);
}
#[test]
fn test_missing_closing_fence() {
// Construct test input using format with multiline string literals
let input = format!(
r#"src/main.rs
```rust
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
<!-- Missing closing fence -->
src/utils.rs
```rust
{}
fn utils_func() {{}}
{}
fn new_utils_func() {{}}
{}
```
"#,
SEARCH_MARKER, DIVIDER, REPLACE_MARKER, SEARCH_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&input);
// Only the second block should be parsed
assert_eq!(actions.len(), 1);
let (action, _) = &actions[0];
assert_eq!(
action,
&EditAction::Replace {
file_path: PathBuf::from("src/utils.rs"),
old: "fn utils_func() {}".to_string(),
new: "fn new_utils_func() {}".to_string(),
}
);
assert_eq!(parser.errors().len(), 1);
assert_eq!(
parser.errors()[0].to_string(),
"input:8:1: Expected marker \"```\", found '<'"
);
// The parser should continue after an error
assert_eq!(parser.state, State::Default);
}
#[test]
fn test_parse_examples_in_edit_prompt() {
let mut parser = EditActionParser::new();
let actions = parser.parse_chunk(&edit_model_prompt());
assert_examples_in_edit_prompt(&actions, parser.errors());
}
#[gpui::test(iterations = 10)]
fn test_random_chunking_of_edit_prompt(mut rng: StdRng) {
let mut parser = EditActionParser::new();
let mut remaining: &str = &edit_model_prompt();
let mut actions = Vec::with_capacity(5);
while !remaining.is_empty() {
let chunk_size = rng.gen_range(1..=std::cmp::min(remaining.len(), 100));
let (chunk, rest) = remaining.split_at(chunk_size);
let chunk_actions = parser.parse_chunk(chunk);
actions.extend(chunk_actions);
remaining = rest;
}
assert_examples_in_edit_prompt(&actions, parser.errors());
}
fn assert_examples_in_edit_prompt(actions: &[(EditAction, String)], errors: &[ParseError]) {
assert_eq!(actions.len(), 5);
assert_eq!(
actions[0].0,
EditAction::Replace {
file_path: PathBuf::from("mathweb/flask/app.py"),
old: "from flask import Flask".to_string(),
new: line_endings!("import math\nfrom flask import Flask").to_string(),
},
);
assert_eq!(
actions[1].0,
EditAction::Replace {
file_path: PathBuf::from("mathweb/flask/app.py"),
old: line_endings!("def factorial(n):\n \"compute factorial\"\n\n if n == 0:\n return 1\n else:\n return n * factorial(n-1)\n").to_string(),
new: "".to_string(),
}
);
assert_eq!(
actions[2].0,
EditAction::Replace {
file_path: PathBuf::from("mathweb/flask/app.py"),
old: " return str(factorial(n))".to_string(),
new: " return str(math.factorial(n))".to_string(),
},
);
assert_eq!(
actions[3].0,
EditAction::Write {
file_path: PathBuf::from("hello.py"),
content: line_endings!(
"def hello():\n \"print a greeting\"\n\n print(\"hello\")"
)
.to_string(),
},
);
assert_eq!(
actions[4].0,
EditAction::Replace {
file_path: PathBuf::from("main.py"),
old: line_endings!(
"def hello():\n \"print a greeting\"\n\n print(\"hello\")"
)
.to_string(),
new: "from hello import hello".to_string(),
},
);
// The system prompt includes some text that would produce errors
assert_eq!(
errors[0].to_string(),
format!(
"input:102:1: Expected marker \"{}\", found '3'",
SEARCH_MARKER
)
);
#[cfg(not(windows))]
assert_eq!(
errors[1].to_string(),
format!(
"input:109:0: Expected marker \"{}\", found '\\n'",
SEARCH_MARKER
)
);
#[cfg(windows)]
assert_eq!(
errors[1].to_string(),
format!(
"input:108:1: Expected marker \"{}\", found '\\r'",
SEARCH_MARKER
)
);
}
#[test]
fn test_print_error() {
let input = format!(
r#"src/main.rs
```rust
{}
fn original() {{}}
{}
fn replacement() {{}}
{}
```
"#,
WRONG_MARKER, DIVIDER, REPLACE_MARKER
);
let mut parser = EditActionParser::new();
parser.parse_chunk(&input);
assert_eq!(parser.errors().len(), 1);
let error = &parser.errors()[0];
let expected_error = format!(
r#"input:3:9: Expected marker "{}", found 'W'"#,
SEARCH_MARKER
);
assert_eq!(format!("{}", error), expected_error);
}
// helpers
fn assert_no_errors(parser: &EditActionParser) {
let errors = parser.errors();
assert!(
errors.is_empty(),
"Expected no errors, but found:\n\n{}",
errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<String>>()
.join("\n")
);
}
}

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