Compare commits

...

40 Commits

Author SHA1 Message Date
Oleksiy Syvokon
3274a6abb0 More FIM templates 2025-12-19 15:09:18 +02:00
Oleksiy Syvokon
e3ffc53c6a Fix panicing when completions point to outdated snapshot 2025-12-19 14:42:52 +02:00
Oleksiy Syvokon
f5cafe5b95 Change rules for the default Ollama models:
- Use qwen2.5-coder only if it's already downloaded
- Otherwise, show a warning to configure the model
2025-12-19 14:22:12 +02:00
Oleksiy Syvokon
2369fc91b4 Settings UI 2025-12-18 21:45:18 +02:00
Oleksiy Syvokon
cc2d1f935f Display configure providers for Ollama 2025-12-18 21:23:51 +02:00
Oleksiy Syvokon
e39dee27cb Enable Ollama provider only if Ollama service is running 2025-12-18 21:05:16 +02:00
Oleksiy Syvokon
d3ebd02828 Fix default model 2025-12-18 20:38:34 +02:00
Oleksiy Syvokon
8062ee53a6 WIP: Initial Ollama edit prediction provider implementation 2025-12-18 20:20:53 +02:00
Ahmed M. Ammar
f9462da2f7 terminal: Fix pane re-entrancy panic when splitting terminal tabs (#45231)
## Summary
Fix panic "cannot update workspace::pane::Pane while it is already being
updated" when dragging terminal tabs to split the pane.

## Problem
When dragging a terminal tab to create a split, the app panics due to
re-entrancy: the drop handler calls `terminal_panel.center.split()`
synchronously, which invokes `mark_positions()` that tries to update all
panes in the group. When the pane being updated is part of the terminal
panel's center group, this causes a re-entrancy panic.

## Solution
Defer the split operation using `cx.spawn_in()`, similar to how
`move_item` was already deferred in the same handler. This ensures the
split (and subsequent `mark_positions()` call) runs after the current
pane update completes.

## Test plan
- Open terminal panel
- Create a terminal tab
- Drag the terminal tab to split the pane
- Verify no panic occurs and split works correctly
2025-12-18 14:34:33 +00:00
Danilo Leal
61dd6a8f31 agent_ui: Add some fixes to tool calling display (#45252)
- Follow up to https://github.com/zed-industries/zed/pull/45097 — not
showing raw inputs for edit and terminal calls
- Removing the display of empty Markdown if the model outputs it

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2025-12-18 14:34:10 +00:00
Ben Brandt
abb199c85e thread_view: Clearer authentication states (#45230)
Closes #44717

Sometimes, we show the user the agent's auth methods because we got an
AuthRequired error.

However, there are also several ways a user can choose to re-enter the
authentication flow even though they are still logged in.

This has caused some confusion with several users, where after logging
in, they type /login again to see if anything changed, and they saw an
"Authentication Required" warning.

So, I made a distinction in the UI if we go to this flow from a concrete
error, or if not, made the language less error-like to help avoid
confusion.

| Before | After |
|--------|--------|
| <img width="1154" height="446" alt="Screenshot 2025-12-18 at 10 
54@2x"
src="https://github.com/user-attachments/assets/9df0d59a-2d45-4bfc-ba85-359dd1a4c8ae"
/> | <img width="1154" height="446" alt="Screenshot 2025-12-18 at 10 
53@2x"
src="https://github.com/user-attachments/assets/73a9fb45-4e6f-4594-8795-aaade35b2a72"
/> |


Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Miguel Raz Guzmán Macedo <miguel@zed.dev>
2025-12-18 14:03:11 +00:00
Lukas Wirth
cebbf77491 gpui(windows): Fix clicks to inactive windows not dispatching to the clicked window (#45237)
Release Notes:

- Fixed an issue on windows where clicking buttons on windows in the
background would not register as being clicked on that window
2025-12-18 13:05:20 +00:00
Ben Brandt
0180f3e72a deepseek: Fix for max output tokens blocking completions (#45236)
They count the requested max_output_tokens against the prompt total.
Seems like a bug on their end as most other providers don't do this, but
now we just default to None for the main models and let the API use its
default behavior which works just fine.

Closes: #45134

Release Notes:

- deepseek: Fix issue with Deepseek API that was causing the token limit
to be reached sooner than necessary
2025-12-18 12:47:34 +00:00
rabsef-bicrym
5488a19221 terminal: Respect RevealStrategy::NoFocus and Never focus settings (#45180)
Closes #45179

## Summary

Fixes the focus behavior when creating terminals with
`RevealStrategy::NoFocus` or `RevealStrategy::Never`. Previously,
terminals would still receive focus if the terminal pane already had
focus, contradicting the documented behavior.

## Changes

- **`add_terminal_task()`**: Changed focus logic to only focus when
`RevealStrategy::Always`
- **`add_terminal_shell()`**: Same fix

The fix changes:
```rust
// Before
let focus = pane.has_focus(window, cx)
    || matches!(reveal_strategy, RevealStrategy::Always);

// After  
let focus = matches!(reveal_strategy, RevealStrategy::Always);
```

## Impact

This affects:
- Vim users running `:!command` (uses `NoFocus`)
- Debugger terminal spawning (uses `NoFocus`)
- Any programmatic terminal creation requesting background behavior

Release Notes:

- Fixed terminal focus behavior to respect `RevealStrategy::NoFocus` and
`RevealStrategy::Never` settings when the terminal pane already has
focus.
2025-12-18 12:11:14 +00:00
Henry Chu
bb1198e7d6 languages: Allow using locally installed ty for Python (#45193)
Release Notes:

- Allow using locally installed `ty` for Python
2025-12-18 12:54:34 +01:00
Kirill Bulatov
69fe27f45e Keep tab stop-less snippets in completion list (#45227)
Closes https://github.com/zed-industries/zed/issues/45083

cc @agu-z 

Release Notes:

- Fixed certain rust-analyzer snippets not shown
2025-12-18 11:29:41 +00:00
Lukas Wirth
469da2fd07 gpui: Fix Windows credential lookup returning error instead of None when credentials don't exist (#45228)
This spams the log with amazon bedrock otherwise

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-18 11:23:11 +00:00
shibang
4f87822133 gpui: Persist window bounds and display when detaching a workspace session (#45201)
Closes #41246 #45092

Release Notes:

- N/A

**Root Cause**:
Empty local workspaces returned `DetachFromSession` from 
`serialize_workspace_location()`, and the `DetachFromSession` handler
only cleared the session_id **without saving window bounds**.

**Fix Applied**:
Modified the `DetachFromSession` handler to save window bounds via
`set_window_open_status()`:
```rust
WorkspaceLocation::DetachFromSession => {
    let window_bounds = SerializedWindowBounds(window.window_bounds());
    let display = window.display(cx).and_then(|d| d.uuid().ok());
    window.spawn(cx, async move |_| {
        persistence::DB
            .set_window_open_status(database_id, window_bounds, display.unwrap_or_default())
            .await.log_err();
        persistence::DB.set_session_id(database_id, None).await.log_err();
    })
}
```

**Recording**:


https://github.com/user-attachments/assets/2b6564d4-4e1b-40fe-943b-147296340aa7
2025-12-18 12:03:42 +01:00
Ben Brandt
9a69d89f88 thread_view: Remove unused acp auth method (#45221)
This was from an early iteration and this code path isn't used anymore

Release Notes:

- N/A
2025-12-18 10:47:36 +00:00
Kirill Bulatov
54f360ace1 Add a test to ensure we invalidate brackets not only on edits (#45219)
Follow-up of https://github.com/zed-industries/zed/pull/45187

Release Notes:

- N/A
2025-12-18 10:42:37 +00:00
Ben Brandt
b2a0b78ece acp: Change default for gemini back to managed version (#45218)
It seems we unintentionally changed the default behavior of if we use
the gemini on the path in #40663

Changing this back so by default we use a managed version of the CLI so
we can better control min versions and the like, but still allow people
to override if they need to.

Release Notes:

- N/A
2025-12-18 10:25:06 +00:00
MostlyK
f1ca2f9f31 workspace: Fix new projects opening with default window size (#45204)
Previously, when opening a new project (one that was never opened
before), the window bounds restoration logic would fall through to
GPUI's default window sizing instead of using the last known window
bounds.

This change consolidates the window bounds restoration logic so that
both empty workspaces and new projects use the stored default window
bounds, making the behavior consistent: any new window will use the last
resized window's size and position.

Closes #45092 

Release Notes:

- Fixed new files and projects opening with default window size instead
of the last used window size.
2025-12-18 09:57:21 +00:00
Guilherme do Amaral Alves
4b34adedd2 Update Mistral models context length to their recommended values (#45194)
I noticed some of mistral models context lenghts were outdated, they
were updated accordingly to mistral documentation.

The following models had their context lenght changed:

[mistral-large-latest](https://docs.mistral.ai/models/mistral-large-3-25-12)

[magistral-medium-latest](https://docs.mistral.ai/models/magistral-medium-1-2-25-09)

[magistral-small-latest](https://docs.mistral.ai/models/magistral-small-1-2-25-09)

[devstral-medium-latest](https://docs.mistral.ai/models/devstral-2-25-12)

[devstral-small-latest](https://docs.mistral.ai/models/devstral-small-2-25-12)
2025-12-18 09:49:32 +00:00
Oleksii Orlenko
df48294caa agent_ui: Remove unnecessary Arc allocation (#45172)
Follow up to https://github.com/zed-industries/zed/pull/44297.

Initial implementation in ce884443f1 used
`Arc` to store the reference to the hash map inside the iterator while
keeping the lifetime static. The code was later simplified in
5151b22e2e to build the list eagerly but
the Arc was forgotten, although it became unnecessary.

cc @bennetbo

Release Notes:

- N/A
2025-12-18 10:48:45 +01:00
Kirill Bulatov
cdc5cc348f Return back the eager snapshot update (#45210)
Based on
https://github.com/zed-industries/zed/pull/45187#discussion_r2630140112

Release Notes:

- N/A

Co-authored-by: Lukas Wirth <lukas@zed.dev>
2025-12-18 09:32:35 +00:00
Kirill Bulatov
0f7f540138 Always invalidate tree-sitter data on buffer reparse end (#45187)
Also do not eagerly invalidate this data on buffer reparse start

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

Release Notes:

- Fixed bracket colorization not applied on initial file open
2025-12-18 02:37:26 +00:00
Kunall Banerjee
184001b33b docs: Add note about conflicting global macOS shortcut (#45186)
This is already noted in our `default-macos.json`, but was never
surfaced in our docs for some reason. A user noted their LSP completions
were not working because they were not aware of the conflicting global
shortcut.

Ref:
https://github.com/zed-industries/zed/issues/44970#issuecomment-3664118523

Release Notes:

- N/A
2025-12-18 02:13:59 +00:00
Xiaobo Liu
225a2a8a20 google_ai: Refactor token count methods in Google AI (#45184)
The change simplifies the `max_token_count` and `max_output_tokens`
methods by grouping Gemini models with identical token limits.

Release Notes:

- N/A
2025-12-17 20:12:40 -06:00
Kirill Bulatov
ea37057814 Restore generic modal closing on mouse click (#45183)
Was removed in
https://github.com/zed-industries/zed/pull/44887/changes#diff-1de872be76a27a9d574a0b0acec4581797446e60743d23b3e7a5f15088fa7e61

Release Notes:

- (Preview only) Fixed certain modals not being dismissed on mouse click
outside
2025-12-18 01:56:12 +00:00
Conrad Irwin
77cdef3596 Attempt to fix the autofix auto scheduler (#45178)
Release Notes:

- N/A
2025-12-18 01:04:12 +00:00
Torstein Sørnes
05108c50fd agent_ui: Make tool call raw input visible (#45097)
<img width="500" height="1246" alt="Screenshot 2025-12-17 at 9  28@2x"
src="https://github.com/user-attachments/assets/eddb290d-d4d0-4ab8-94b3-bcc50ad07157"
/>

Release Notes:

- agent: Made tool calls' raw input visible in the agent UI.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-12-18 00:34:31 +00:00
Ben Kunkle
07538ff08e Make sweep and mercury API tokens use cx.global instead of OnceLock (#45176)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-12-18 00:32:46 +00:00
Cole Miller
9073a2666c Revert "git: Mark entries as pending when staging a files making the staged highlighting more "optimistic"" (#45175)
Reverts zed-industries/zed#43434

This caused a regression because the additional pending hunks don't get
cleared.
2025-12-18 00:28:09 +00:00
Max Brunsfeld
843a35a1a9 extension api: Make server id types constructible, to ease writing tests (#45174)
Currently, extensions cannot have tests that call methods like
`label_for_symbol` and `label_for_completion`, because those methods
take a `LanguageServerId`, and that type is opaque, and cannot be
constructed outside of the `zed_extension_api` crate.

This PR makes it possible to construct those types from strings, so that
it's more straightforward to write unit tests for these LSP adapter
methods.

Release Notes:

- N/A
2025-12-17 16:25:07 -08:00
Conrad Irwin
aff93f2f6c More permissions for autofix (#45170)
Release Notes:

- N/A
2025-12-17 17:05:35 -07:00
Kingsword
0c9992c5e9 terminal: Forward Ctrl+V when clipboard contains images (#42258)
When running Codex CLI, Claude Code, or other TUI agents in Zed’s
terminal, pasting images wasn’t supported — Zed
treated all clipboard content as plain text and simply pushed it into
the PTY, so the agent never saw the image data.
This change makes terminal pastes behave like they do in a native
terminal: if the clipboard contains an image, Zed now emits a raw Ctrl+V
to the PTY so the agent can read the system clipboard itself.

Release Notes:

- Fixed terminal-launched Codex/Claude sessions by forwarding Ctrl+V for
clipboard images so agents can attach them
2025-12-17 20:42:47 -03:00
Mayank Verma
cec46079fe git_ui: Preserve newlines in commit messages (#45167)
Closes #44982

Release Notes:

- Fixed Git panel to preserve newlines in commit messages
2025-12-17 22:52:10 +00:00
Ben Kunkle
f9b69aeff0 Fix Wayland platform resize resulting in non-interactive window (#45153)
Closes  #40361

Release Notes:

- Linux(Wayland): Fixed an issue where the settings window would not
respond to user interaction until resized
2025-12-17 17:44:25 -05:00
Nathan Sobo
f00cb371f4 macOS: Bundle placeholder Document.icns so Finder can display Zed file icons (#44833)
Generated by AI.

`DocumentTypes.plist` declares `CFBundleTypeIconFile` as `Document` for
Zed’s document types, but the macOS bundle did not include
`Contents/Resources/Document.icns`, causing Finder to fall back to
generic icons.

This PR:
- Adds `crates/zed/resources/Document.icns` as a placeholder document
icon (currently derived from the app icon).
- Updates `script/bundle-mac` to copy it into the `.app` at
`Contents/Resources/Document.icns` during bundling.
- Adds `script/verify-macos-document-icon` for one-command validation.

## How to test (CLI)
1. Build a debug bundle:
   - `./script/bundle-mac -d aarch64-apple-darwin`
2. Verify the bundle contains the referenced icon:
- `./script/verify-macos-document-icon
"target/aarch64-apple-darwin/debug/bundle/osx/Zed Dev.app"`

## Optional visual validation in Finder
- Pick a file (e.g. `.rs`), Get Info → Open with: Zed Dev → Change
All...
- Restart Finder: `killall Finder` (or log out/in)

@JosephTLyons — would you mind running the steps above and confirming
Finder shows Zed’s icon for source files after "Change All" + Finder
restart?

@danilo-leal — this PR ships a placeholder `Document.icns`. When the
real document icon is ready, replace
`crates/zed/resources/Document.icns` and the bundling script will
include it automatically.


Closes #44403.

Release Notes:

- TODO

---------

Co-authored-by: Matt Miller <mattrx@gmail.com>
2025-12-17 16:42:31 -06:00
Ben Kunkle
25e1e2ecdd Don't trigger autosave on focus change in modals (#45166)
Closes #28732

Release Notes:

- Opening the command palette or other modals no longer triggers
auto-save with the `{ "autosave": "on_focus_change" }` setting. This
reduces the chance of unwanted format changes when executing actions,
and fixes a race condition with `:w` in Vim mode
2025-12-17 17:42:18 -05:00
52 changed files with 1566 additions and 503 deletions

View File

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

View File

@@ -26,7 +26,8 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- name: steps::clippy
- id: clippy
name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::clear_target_dir_if_large
@@ -71,15 +72,15 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- name: steps::clippy
- id: clippy
name: steps::clippy
run: ./script/clippy
shell: bash -euxo pipefail {0}
- name: steps::trigger_autofix
if: failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'
run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=true
- id: record_clippy_failure
name: steps::record_clippy_failure
if: always()
run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT"
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: steps::cargo_install_nextest
uses: taiki-e/install-action@nextest
- name: steps::clear_target_dir_if_large
@@ -93,6 +94,8 @@ jobs:
run: |
rm -rf ./../.cargo
shell: bash -euxo pipefail {0}
outputs:
clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }}
timeout-minutes: 60
run_tests_windows:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
@@ -111,7 +114,8 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: '20'
- name: steps::clippy
- id: clippy
name: steps::clippy
run: ./script/clippy.ps1
shell: pwsh
- name: steps::clear_target_dir_if_large

View File

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

View File

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

11
Cargo.lock generated
View File

@@ -5320,6 +5320,7 @@ dependencies = [
"client",
"gpui",
"language",
"log",
"text",
]
@@ -5344,11 +5345,13 @@ dependencies = [
"gpui",
"indoc",
"language",
"language_model",
"log",
"lsp",
"markdown",
"menu",
"multi_buffer",
"ollama",
"paths",
"project",
"regex",
@@ -10880,12 +10883,19 @@ name = "ollama"
version = "0.1.0"
dependencies = [
"anyhow",
"edit_prediction_context",
"edit_prediction_types",
"futures 0.3.31",
"gpui",
"http_client",
"language",
"language_model",
"log",
"schemars",
"serde",
"serde_json",
"settings",
"text",
]
[[package]]
@@ -20697,6 +20707,7 @@ dependencies = [
"nc",
"node_runtime",
"notifications",
"ollama",
"onboarding",
"outline",
"outline_panel",

View File

@@ -1422,6 +1422,10 @@
"model": "codestral-latest",
"max_tokens": 150,
},
"ollama": {
"api_url": "http://localhost:11434",
"model": "qwen2.5-coder:3b-base",
},
// Whether edit predictions are enabled when editing text threads in the agent panel.
// This setting has no effect if globally disabled.
"enabled_in_text_threads": true,

View File

@@ -192,6 +192,7 @@ pub struct ToolCall {
pub locations: Vec<acp::ToolCallLocation>,
pub resolved_locations: Vec<Option<AgentLocation>>,
pub raw_input: Option<serde_json::Value>,
pub raw_input_markdown: Option<Entity<Markdown>>,
pub raw_output: Option<serde_json::Value>,
}
@@ -222,6 +223,11 @@ impl ToolCall {
}
}
let raw_input_markdown = tool_call
.raw_input
.as_ref()
.and_then(|input| markdown_for_raw_output(input, &language_registry, cx));
let result = Self {
id: tool_call.tool_call_id,
label: cx
@@ -232,6 +238,7 @@ impl ToolCall {
resolved_locations: Vec::default(),
status,
raw_input: tool_call.raw_input,
raw_input_markdown,
raw_output: tool_call.raw_output,
};
Ok(result)
@@ -307,6 +314,7 @@ impl ToolCall {
}
if let Some(raw_input) = raw_input {
self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx);
self.raw_input = Some(raw_input);
}
@@ -1355,6 +1363,7 @@ impl AcpThread {
locations: Vec::new(),
resolved_locations: Vec::new(),
raw_input: None,
raw_input_markdown: None,
raw_output: None,
};
self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx);

View File

@@ -221,7 +221,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let favorites = if self.selector.supports_favorites() {
Arc::new(AgentSettings::get_global(cx).favorite_model_ids())
AgentSettings::get_global(cx).favorite_model_ids()
} else {
Default::default()
};
@@ -242,7 +242,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_entries =
info_list_to_picker_entries(filtered_models, favorites);
info_list_to_picker_entries(filtered_models, &favorites);
// Finds the currently selected model in the list
let new_index = this
.delegate
@@ -406,7 +406,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
fn info_list_to_picker_entries(
model_list: AgentModelList,
favorites: Arc<HashSet<ModelId>>,
favorites: &HashSet<ModelId>,
) -> Vec<AcpModelPickerEntry> {
let mut entries = Vec::new();
@@ -572,13 +572,11 @@ mod tests {
}
}
fn create_favorites(models: Vec<&str>) -> Arc<HashSet<ModelId>> {
Arc::new(
models
.into_iter()
.map(|m| ModelId::new(m.to_string()))
.collect(),
)
fn create_favorites(models: Vec<&str>) -> HashSet<ModelId> {
models
.into_iter()
.map(|m| ModelId::new(m.to_string()))
.collect()
}
fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> {
@@ -609,7 +607,7 @@ mod tests {
]);
let favorites = create_favorites(vec!["zed/gemini"]);
let entries = info_list_to_picker_entries(models, favorites);
let entries = info_list_to_picker_entries(models, &favorites);
assert!(matches!(
entries.first(),
@@ -625,7 +623,7 @@ mod tests {
let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]);
let favorites = create_favorites(vec![]);
let entries = info_list_to_picker_entries(models, favorites);
let entries = info_list_to_picker_entries(models, &favorites);
assert!(matches!(
entries.first(),
@@ -641,7 +639,7 @@ mod tests {
]);
let favorites = create_favorites(vec!["zed/claude"]);
let entries = info_list_to_picker_entries(models, favorites);
let entries = info_list_to_picker_entries(models, &favorites);
for entry in &entries {
if let AcpModelPickerEntry::Model(info, is_favorite) = entry {
@@ -662,7 +660,7 @@ mod tests {
]);
let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]);
let entries = info_list_to_picker_entries(models, favorites);
let entries = info_list_to_picker_entries(models, &favorites);
let model_ids = get_entry_model_ids(&entries);
assert_eq!(model_ids[0], "zed/gemini");
@@ -683,7 +681,7 @@ mod tests {
let favorites = create_favorites(vec!["zed/claude"]);
let entries = info_list_to_picker_entries(models, favorites);
let entries = info_list_to_picker_entries(models, &favorites);
let labels = get_entry_labels(&entries);
assert_eq!(
@@ -723,7 +721,7 @@ mod tests {
]);
let favorites = create_favorites(vec!["zed/gemini"]);
let entries = info_list_to_picker_entries(models, favorites);
let entries = info_list_to_picker_entries(models, &favorites);
assert!(matches!(
entries.first(),

View File

@@ -34,7 +34,7 @@ use language::Buffer;
use language_model::LanguageModelRegistry;
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
use project::{Project, ProjectEntryId};
use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId};
use prompt_store::{PromptId, PromptStore};
use rope::Point;
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore};
@@ -260,6 +260,7 @@ impl ThreadFeedbackState {
pub struct AcpThreadView {
agent: Rc<dyn AgentServer>,
agent_server_store: Entity<AgentServerStore>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
thread_state: ThreadState,
@@ -406,6 +407,7 @@ impl AcpThreadView {
Self {
agent: agent.clone(),
agent_server_store,
workspace: workspace.clone(),
project: project.clone(),
entry_view_state,
@@ -737,7 +739,7 @@ impl AcpThreadView {
cx: &mut App,
) {
let agent_name = agent.name();
let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id {
let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id {
let registry = LanguageModelRegistry::global(cx);
let sub = window.subscribe(&registry, cx, {
@@ -779,7 +781,6 @@ impl AcpThreadView {
configuration_view,
description: err
.description
.clone()
.map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))),
_subscription: subscription,
};
@@ -1088,10 +1089,7 @@ impl AcpThreadView {
window.defer(cx, |window, cx| {
Self::handle_auth_required(
this,
AuthRequired {
description: None,
provider_id: None,
},
AuthRequired::new(),
agent,
connection,
window,
@@ -1663,44 +1661,6 @@ impl AcpThreadView {
});
return;
}
} else if method.0.as_ref() == "anthropic-api-key" {
let registry = LanguageModelRegistry::global(cx);
let provider = registry
.read(cx)
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
.unwrap();
let this = cx.weak_entity();
let agent = self.agent.clone();
let connection = connection.clone();
window.defer(cx, move |window, cx| {
if !provider.is_authenticated(cx) {
Self::handle_auth_required(
this,
AuthRequired {
description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
},
agent,
connection,
window,
cx,
);
} else {
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
this.project.clone(),
true,
window,
cx,
)
})
.ok();
}
});
return;
} else if method.0.as_ref() == "vertex-ai"
&& std::env::var("GOOGLE_API_KEY").is_err()
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
@@ -2153,6 +2113,7 @@ impl AcpThreadView {
chunks,
indented: _,
}) => {
let mut is_blank = true;
let is_last = entry_ix + 1 == total_entries;
let style = default_markdown_style(false, false, window, cx);
@@ -2162,36 +2123,55 @@ impl AcpThreadView {
.children(chunks.iter().enumerate().filter_map(
|(chunk_ix, chunk)| match chunk {
AssistantMessageChunk::Message { block } => {
block.markdown().map(|md| {
self.render_markdown(md.clone(), style.clone())
.into_any_element()
block.markdown().and_then(|md| {
let this_is_blank = md.read(cx).source().trim().is_empty();
is_blank = is_blank && this_is_blank;
if this_is_blank {
return None;
}
Some(
self.render_markdown(md.clone(), style.clone())
.into_any_element(),
)
})
}
AssistantMessageChunk::Thought { block } => {
block.markdown().map(|md| {
self.render_thinking_block(
entry_ix,
chunk_ix,
md.clone(),
window,
cx,
block.markdown().and_then(|md| {
let this_is_blank = md.read(cx).source().trim().is_empty();
is_blank = is_blank && this_is_blank;
if this_is_blank {
return None;
}
Some(
self.render_thinking_block(
entry_ix,
chunk_ix,
md.clone(),
window,
cx,
)
.into_any_element(),
)
.into_any_element()
})
}
},
))
.into_any();
v_flex()
.px_5()
.py_1p5()
.when(is_first_indented, |this| this.pt_0p5())
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(message_body)
.into_any()
if is_blank {
Empty.into_any()
} else {
v_flex()
.px_5()
.py_1p5()
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(message_body)
.into_any()
}
}
AgentThreadEntry::ToolCall(tool_call) => {
let has_terminals = tool_call.terminals().next().is_some();
@@ -2223,7 +2203,7 @@ impl AcpThreadView {
div()
.relative()
.w_full()
.pl(rems_from_px(20.0))
.pl_5()
.bg(cx.theme().colors().panel_background.opacity(0.2))
.child(
div()
@@ -2440,6 +2420,12 @@ impl AcpThreadView {
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
let input_output_header = |label: SharedString| {
Label::new(label)
.size(LabelSize::XSmall)
.color(Color::Muted)
.buffer_font(cx)
};
let tool_output_display =
if is_open {
@@ -2481,7 +2467,25 @@ impl AcpThreadView {
| ToolCallStatus::Completed
| ToolCallStatus::Failed
| ToolCallStatus::Canceled => v_flex()
.w_full()
.when(!is_edit && !is_terminal_tool, |this| {
this.mt_1p5().w_full().child(
v_flex()
.ml(rems(0.4))
.px_3p5()
.pb_1()
.gap_1()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
.child(input_output_header("Raw Input:".into()))
.children(tool_call.raw_input_markdown.clone().map(|input| {
self.render_markdown(
input,
default_markdown_style(false, false, window, cx),
)
}))
.child(input_output_header("Output:".into())),
)
})
.children(tool_call.content.iter().enumerate().map(
|(content_ix, content)| {
div().child(self.render_tool_call_content(
@@ -2580,7 +2584,7 @@ impl AcpThreadView {
.gap_px()
.when(is_collapsible, |this| {
this.child(
Disclosure::new(("expand", entry_ix), is_open)
Disclosure::new(("expand-output", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.visible_on_hover(&card_header_id)
@@ -2766,20 +2770,20 @@ impl AcpThreadView {
let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id));
v_flex()
.mt_1p5()
.gap_2()
.when(!card_layout, |this| {
this.ml(rems(0.4))
.px_3p5()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
})
.when(card_layout, |this| {
this.px_2().pb_2().when(context_ix > 0, |this| {
this.border_t_1()
.pt_2()
.map(|this| {
if card_layout {
this.when(context_ix > 0, |this| {
this.pt_2()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
})
} else {
this.ml(rems(0.4))
.px_3p5()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
})
}
})
.text_xs()
.text_color(cx.theme().colors().text_muted)
@@ -3500,138 +3504,119 @@ impl AcpThreadView {
pending_auth_method: Option<&acp::AuthMethodId>,
window: &mut Window,
cx: &Context<Self>,
) -> Div {
let show_description =
configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
) -> impl IntoElement {
let auth_methods = connection.auth_methods();
v_flex().flex_1().size_full().justify_end().child(
v_flex()
.p_2()
.pr_3()
.w_full()
.gap_1()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().status().warning.opacity(0.04))
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Warning)
.color(Color::Warning)
.size(IconSize::Small),
)
.child(Label::new("Authentication Required").size(LabelSize::Small)),
)
.children(description.map(|desc| {
div().text_ui(cx).child(self.render_markdown(
desc.clone(),
default_markdown_style(false, false, window, cx),
))
}))
.children(
configuration_view
.cloned()
.map(|view| div().w_full().child(view)),
)
.when(show_description, |el| {
el.child(
Label::new(format!(
"You are not currently authenticated with {}.{}",
self.agent.name(),
if auth_methods.len() > 1 {
" Please choose one of the following options:"
} else {
""
}
))
.size(LabelSize::Small)
.color(Color::Muted)
.mb_1()
.ml_5(),
)
})
.when_some(pending_auth_method, |el, _| {
el.child(
h_flex()
.py_4()
.w_full()
.justify_center()
.gap_1()
.child(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2),
)
.child(Label::new("Authenticating…").size(LabelSize::Small)),
)
})
.when(!auth_methods.is_empty(), |this| {
this.child(
h_flex()
.justify_end()
.flex_wrap()
.gap_1()
.when(!show_description, |this| {
this.border_t_1()
.mt_1()
.pt_2()
.border_color(cx.theme().colors().border.opacity(0.8))
let agent_display_name = self
.agent_server_store
.read(cx)
.agent_display_name(&ExternalAgentServerName(self.agent.name()))
.unwrap_or_else(|| self.agent.name());
let show_fallback_description = auth_methods.len() > 1
&& configuration_view.is_none()
&& description.is_none()
&& pending_auth_method.is_none();
let auth_buttons = || {
h_flex().justify_end().flex_wrap().gap_1().children(
connection
.auth_methods()
.iter()
.enumerate()
.rev()
.map(|(ix, method)| {
let (method_id, name) = if self.project.read(cx).is_via_remote_server()
&& method.id.0.as_ref() == "oauth-personal"
&& method.name == "Log in with Google"
{
("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
} else {
(method.id.0.clone(), method.name.clone())
};
let agent_telemetry_id = connection.telemetry_id();
Button::new(method_id.clone(), name)
.label_size(LabelSize::Small)
.map(|this| {
if ix == 0 {
this.style(ButtonStyle::Tinted(TintColor::Accent))
} else {
this.style(ButtonStyle::Outlined)
}
})
.children(connection.auth_methods().iter().enumerate().rev().map(
|(ix, method)| {
let (method_id, name) = if self
.project
.read(cx)
.is_via_remote_server()
&& method.id.0.as_ref() == "oauth-personal"
&& method.name == "Log in with Google"
{
("spawn-gemini-cli".into(), "Log in with Gemini CLI".into())
} else {
(method.id.0.clone(), method.name.clone())
};
.when_some(method.description.clone(), |this, description| {
this.tooltip(Tooltip::text(description))
})
.on_click({
cx.listener(move |this, _, window, cx| {
telemetry::event!(
"Authenticate Agent Started",
agent = agent_telemetry_id,
method = method_id
);
let agent_telemetry_id = connection.telemetry_id();
this.authenticate(
acp::AuthMethodId::new(method_id.clone()),
window,
cx,
)
})
})
}),
)
};
Button::new(method_id.clone(), name)
.label_size(LabelSize::Small)
.map(|this| {
if ix == 0 {
this.style(ButtonStyle::Tinted(TintColor::Warning))
} else {
this.style(ButtonStyle::Outlined)
}
})
.when_some(
method.description.clone(),
|this, description| {
this.tooltip(Tooltip::text(description))
},
)
.on_click({
cx.listener(move |this, _, window, cx| {
telemetry::event!(
"Authenticate Agent Started",
agent = agent_telemetry_id,
method = method_id
);
if pending_auth_method.is_some() {
return Callout::new()
.icon(IconName::Info)
.title(format!("Authenticating to {}", agent_display_name))
.actions_slot(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2)
.into_any_element(),
)
.into_any_element();
}
this.authenticate(
acp::AuthMethodId::new(method_id.clone()),
window,
cx,
)
})
})
},
)),
)
}),
)
Callout::new()
.icon(IconName::Info)
.title(format!("Authenticate to {}", agent_display_name))
.when(auth_methods.len() == 1, |this| {
this.actions_slot(auth_buttons())
})
.description_slot(
v_flex()
.text_ui(cx)
.map(|this| {
if show_fallback_description {
this.child(
Label::new("Choose one of the following authentication options:")
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.children(
configuration_view
.cloned()
.map(|view| div().w_full().child(view)),
)
.children(description.map(|desc| {
self.render_markdown(
desc.clone(),
default_markdown_style(false, false, window, cx),
)
}))
}
})
.when(auth_methods.len() > 1, |this| {
this.gap_1().child(auth_buttons())
}),
)
.into_any_element()
}
fn render_load_error(
@@ -5880,10 +5865,6 @@ impl AcpThreadView {
};
let connection = thread.read(cx).connection().clone();
let err = AuthRequired {
description: None,
provider_id: None,
};
this.clear_thread_error(cx);
if let Some(message) = this.in_flight_prompt.take() {
this.message_editor.update(cx, |editor, cx| {
@@ -5892,7 +5873,14 @@ impl AcpThreadView {
}
let this = cx.weak_entity();
window.defer(cx, |window, cx| {
Self::handle_auth_required(this, err, agent, connection, window, cx);
Self::handle_auth_required(
this,
AuthRequired::new(),
agent,
connection,
window,
cx,
);
})
}
}))
@@ -5905,14 +5893,10 @@ impl AcpThreadView {
};
let connection = thread.read(cx).connection().clone();
let err = AuthRequired {
description: None,
provider_id: None,
};
self.clear_thread_error(cx);
let this = cx.weak_entity();
window.defer(cx, |window, cx| {
Self::handle_auth_required(this, err, agent, connection, window, cx);
Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx);
})
}
@@ -6015,16 +5999,19 @@ impl Render for AcpThreadView {
configuration_view,
pending_auth_method,
..
} => self
.render_auth_required_state(
} => v_flex()
.flex_1()
.size_full()
.justify_end()
.child(self.render_auth_required_state(
connection,
description.as_ref(),
configuration_view.as_ref(),
pending_auth_method.as_ref(),
window,
cx,
)
.into_any(),
))
.into_any_element(),
ThreadState::Loading { .. } => v_flex()
.flex_1()
.child(self.render_recent_history(cx))

View File

@@ -321,6 +321,7 @@ fn update_command_palette_filter(cx: &mut App) {
}
EditPredictionProvider::Zed
| EditPredictionProvider::Codestral
| EditPredictionProvider::Ollama
| EditPredictionProvider::Experimental(_) => {
filter.show_namespace("edit_prediction");
filter.hide_namespace("copilot");

View File

@@ -103,8 +103,9 @@ impl Model {
pub fn max_output_tokens(&self) -> Option<u64> {
match self {
Self::Chat => Some(8_192),
Self::Reasoner => Some(64_000),
// Their API treats this max against the context window, which means we hit the limit a lot
// Using the default value of None in the API instead
Self::Chat | Self::Reasoner => None,
Self::Custom {
max_output_tokens, ..
} => *max_output_tokens,

View File

@@ -6,7 +6,7 @@ use crate::{
use anyhow::{Context as _, Result};
use futures::AsyncReadExt as _;
use gpui::{
App, AppContext as _, Entity, SharedString, Task,
App, AppContext as _, Entity, Global, SharedString, Task,
http_client::{self, AsyncBody, Method},
};
use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
@@ -300,14 +300,19 @@ pub const MERCURY_CREDENTIALS_URL: SharedString =
SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
struct GlobalMercuryApiKey(Entity<ApiKeyState>);
impl Global for GlobalMercuryApiKey {}
pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
MERCURY_API_KEY
.get_or_init(|| {
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()))
})
.clone()
if let Some(global) = cx.try_global::<GlobalMercuryApiKey>() {
return global.0.clone();
}
let entity =
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()));
cx.set_global(GlobalMercuryApiKey(entity.clone()));
entity
}
pub fn load_mercury_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
use futures::AsyncReadExt as _;
use gpui::{
App, AppContext as _, Entity, SharedString, Task,
App, AppContext as _, Entity, Global, SharedString, Task,
http_client::{self, AsyncBody, Method},
};
use language::{Point, ToOffset as _};
@@ -272,14 +272,19 @@ pub const SWEEP_CREDENTIALS_URL: SharedString =
SharedString::new_static("https://autocomplete.sweep.dev");
pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
struct GlobalSweepApiKey(Entity<ApiKeyState>);
impl Global for GlobalSweepApiKey {}
pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
SWEEP_API_KEY
.get_or_init(|| {
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()))
})
.clone()
if let Some(global) = cx.try_global::<GlobalSweepApiKey>() {
return global.0.clone();
}
let entity =
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()));
cx.set_global(GlobalSweepApiKey(entity.clone()));
entity
}
pub fn load_sweep_api_token(cx: &mut App) -> Task<Result<(), language_model::AuthenticateError>> {

View File

@@ -16,3 +16,4 @@ client.workspace = true
gpui.workspace = true
language.workspace = true
text.workspace = true
log.workspace = true

View File

@@ -231,6 +231,10 @@ pub enum EditPredictionGranularity {
}
/// Returns edits updated based on user edits since the old snapshot. None is returned if any user
/// edit is not a prefix of a predicted insertion.
///
/// This function is intentionally defensive: edit prediction providers may hold onto anchors from
/// an older snapshot. Converting those anchors to offsets can panic if the buffer version no longer
/// observes the anchor's timestamp. In that case, we treat the prediction as stale and return None.
pub fn interpolate_edits(
old_snapshot: &text::BufferSnapshot,
new_snapshot: &text::BufferSnapshot,
@@ -241,8 +245,12 @@ pub fn interpolate_edits(
let mut model_edits = current_edits.iter().peekable();
for user_edit in new_snapshot.edits_since::<usize>(&old_snapshot.version) {
while let Some((model_old_range, _)) = model_edits.peek() {
let model_old_range = model_old_range.to_offset(old_snapshot);
if model_old_range.end < user_edit.old.start {
let Some(model_old_offset_range) = safe_to_offset_range(old_snapshot, model_old_range)
else {
return None;
};
if model_old_offset_range.end < user_edit.old.start {
let (model_old_range, model_new_text) = model_edits.next().unwrap();
edits.push((model_old_range.clone(), model_new_text.clone()));
} else {
@@ -251,7 +259,11 @@ pub fn interpolate_edits(
}
if let Some((model_old_range, model_new_text)) = model_edits.peek() {
let model_old_offset_range = model_old_range.to_offset(old_snapshot);
let Some(model_old_offset_range) = safe_to_offset_range(old_snapshot, model_old_range)
else {
return None;
};
if user_edit.old == model_old_offset_range {
let user_new_text = new_snapshot
.text_for_range(user_edit.new.clone())
@@ -272,7 +284,38 @@ pub fn interpolate_edits(
return None;
}
// If any remaining edit ranges can't be converted safely, treat the prediction as stale.
if model_edits
.clone()
.any(|(range, _)| safe_to_offset_range(old_snapshot, range).is_none())
{
return None;
}
edits.extend(model_edits.cloned());
if edits.is_empty() { None } else { Some(edits) }
}
fn safe_to_offset_range(
snapshot: &text::BufferSnapshot,
range: &Range<Anchor>,
) -> Option<std::ops::Range<usize>> {
// Min/max anchors are always safe to convert.
let start_ok = range.start.is_min()
|| range.start.is_max()
|| snapshot.version.observed(range.start.timestamp);
let end_ok =
range.end.is_min() || range.end.is_max() || snapshot.version.observed(range.end.timestamp);
if start_ok && end_ok {
Some(range.to_offset(snapshot))
} else {
log::debug!(
"Dropping stale edit prediction range because anchor timestamps are not observed by snapshot version (start_ok: {}, end_ok: {})",
start_ok,
end_ok
);
None
}
}

View File

@@ -32,9 +32,11 @@ futures.workspace = true
gpui.workspace = true
indoc.workspace = true
language.workspace = true
language_model.workspace = true
markdown.workspace = true
menu.workspace = true
multi_buffer.workspace = true
ollama.workspace = true
paths.workspace = true
project.workspace = true
regex.workspace = true

View File

@@ -22,6 +22,7 @@ use language::{
EditPredictionsMode, File, Language,
language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
};
use ollama::OllamaEditPredictionDelegate;
use project::DisableAiSettings;
use regex::Regex;
use settings::{
@@ -91,9 +92,9 @@ impl Render for EditPredictionButton {
return div().hidden();
}
let all_language_settings = all_language_settings(None, cx);
let language_settings = all_language_settings(None, cx);
match all_language_settings.edit_predictions.provider {
match language_settings.edit_predictions.provider {
EditPredictionProvider::Copilot => {
let Some(copilot) = Copilot::global(cx) else {
return div().hidden();
@@ -293,6 +294,60 @@ impl Render for EditPredictionButton {
.with_handle(self.popover_menu_handle.clone()),
)
}
EditPredictionProvider::Ollama => {
let enabled = self.editor_enabled.unwrap_or(true);
let this = cx.weak_entity();
div().child(
PopoverMenu::new("ollama")
.menu(move |window, cx| {
this.update(cx, |this, cx| {
this.build_edit_prediction_context_menu(
EditPredictionProvider::Ollama,
window,
cx,
)
})
.ok()
})
.anchor(Corner::BottomRight)
.trigger_with_tooltip(
IconButton::new("ollama-icon", IconName::AiOllama)
.shape(IconButtonShape::Square)
.when(!enabled, |this| {
this.indicator(Indicator::dot().color(Color::Ignored))
.indicator_border_color(Some(
cx.theme().colors().status_bar_background,
))
}),
move |_window, cx| {
let settings = all_language_settings(None, cx);
let tooltip_meta = match settings
.edit_predictions
.ollama
.model
.as_deref()
{
Some(model) if !model.trim().is_empty() => {
format!("Powered by Ollama ({model})")
}
_ => {
"Ollama model not configured — configure a model before use"
.to_string()
}
};
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
tooltip_meta,
cx,
)
},
)
.with_handle(self.popover_menu_handle.clone()),
)
}
provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => {
let enabled = self.editor_enabled.unwrap_or(true);
@@ -547,6 +602,10 @@ impl EditPredictionButton {
providers.push(EditPredictionProvider::Codestral);
}
if OllamaEditPredictionDelegate::is_available(cx) {
providers.push(EditPredictionProvider::Ollama);
}
if cx.has_flag::<SweepFeatureFlag>()
&& edit_prediction::sweep_ai::sweep_api_token(cx)
.read(cx)
@@ -595,6 +654,7 @@ impl EditPredictionButton {
EditPredictionProvider::Copilot => "GitHub Copilot",
EditPredictionProvider::Supermaven => "Supermaven",
EditPredictionProvider::Codestral => "Codestral",
EditPredictionProvider::Ollama => "Ollama",
EditPredictionProvider::Experimental(
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
) => "Sweep",

View File

@@ -348,6 +348,61 @@ where
);
}
#[gpui::test]
async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) {
init_test(cx, |language_settings| {
language_settings.defaults.colorize_brackets = Some(true);
});
let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
language_registry.add(markdown_lang());
language_registry.add(rust_lang());
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language_registry(language_registry.clone());
buffer.set_language(Some(markdown_lang()), cx);
});
cx.set_state(indoc! {r#"
fn main() {
let v: Vec<Stringˇ> = vec![];
}
"#});
cx.executor().advance_clock(Duration::from_millis(100));
cx.executor().run_until_parked();
assert_eq!(
r#"fn main«1()1» «1{
let v: Vec<String> = vec!«2[]2»;
}1»
1 hsla(207.80, 16.20%, 69.19%, 1.00)
2 hsla(29.00, 54.00%, 65.88%, 1.00)
"#,
&bracket_colors_markup(&mut cx),
"Markdown does not colorize <> brackets"
);
cx.update_buffer(|buffer, cx| {
buffer.set_language(Some(rust_lang()), cx);
});
cx.executor().advance_clock(Duration::from_millis(100));
cx.executor().run_until_parked();
assert_eq!(
r#"fn main«1()1» «1{
let v: Vec«2<String>2» = vec!«2[]2»;
}1»
1 hsla(207.80, 16.20%, 69.19%, 1.00)
2 hsla(29.00, 54.00%, 65.88%, 1.00)
"#,
&bracket_colors_markup(&mut cx),
"After switching to Rust, <> brackets are now colorized"
);
}
#[gpui::test]
async fn test_bracket_colorization_when_editing(cx: &mut gpui::TestAppContext) {
init_test(cx, |language_settings| {

View File

@@ -331,7 +331,6 @@ static mut EXTENSION: Option<Box<dyn Extension>> = None;
pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes"));
mod wit {
wit_bindgen::generate!({
skip: ["init-extension"],
path: "./wit/since_v0.8.0",
@@ -524,6 +523,12 @@ impl wit::Guest for Component {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct LanguageServerId(String);
impl LanguageServerId {
pub fn new(value: String) -> Self {
Self(value)
}
}
impl AsRef<str> for LanguageServerId {
fn as_ref(&self) -> &str {
&self.0
@@ -540,6 +545,12 @@ impl fmt::Display for LanguageServerId {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct ContextServerId(String);
impl ContextServerId {
pub fn new(value: String) -> Self {
Self(value)
}
}
impl AsRef<str> for ContextServerId {
fn as_ref(&self) -> &str {
&self.0

View File

@@ -15,6 +15,7 @@ use askpass::AskPassDelegate;
use cloud_llm_client::CompletionIntent;
use collections::{BTreeMap, HashMap, HashSet};
use db::kvp::KEY_VALUE_STORE;
use editor::RewrapOptions;
use editor::{
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
actions::ExpandAllDiffHunks,
@@ -2180,7 +2181,13 @@ impl GitPanel {
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
let wrapped_message = editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx);
editor.rewrap(&Default::default(), window, cx);
editor.rewrap_impl(
RewrapOptions {
override_language_settings: false,
preserve_existing_whitespace: true,
},
cx,
);
editor.text(cx)
});
if wrapped_message.trim().is_empty() {

View File

@@ -566,22 +566,22 @@ impl Model {
pub fn max_token_count(&self) -> u64 {
match self {
Self::Gemini25FlashLite => 1_048_576,
Self::Gemini25Flash => 1_048_576,
Self::Gemini25Pro => 1_048_576,
Self::Gemini3Pro => 1_048_576,
Self::Gemini3Flash => 1_048_576,
Self::Gemini25FlashLite
| Self::Gemini25Flash
| Self::Gemini25Pro
| Self::Gemini3Pro
| Self::Gemini3Flash => 1_048_576,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
pub fn max_output_tokens(&self) -> Option<u64> {
match self {
Model::Gemini25FlashLite => Some(65_536),
Model::Gemini25Flash => Some(65_536),
Model::Gemini25Pro => Some(65_536),
Model::Gemini3Pro => Some(65_536),
Model::Gemini3Flash => Some(65_536),
Model::Gemini25FlashLite
| Model::Gemini25Flash
| Model::Gemini25Pro
| Model::Gemini3Pro
| Model::Gemini3Flash => Some(65_536),
Model::Custom { .. } => None,
}
}

View File

@@ -1025,13 +1025,26 @@ impl PlatformWindow for WaylandWindow {
fn resize(&mut self, size: Size<Pixels>) {
let state = self.borrow();
let state_ptr = self.0.clone();
let dp_size = size.to_device_pixels(self.scale_factor());
// Keep window geometry consistent with configure handling. On Wayland, window geometry is
// surface-local: resizing should not attempt to translate the window; the compositor
// controls placement. We also account for client-side decoration insets and tiling.
let window_geometry = inset_by_tiling(
Bounds {
origin: Point::default(),
size,
},
state.inset(),
state.tiling,
)
.map(|v| v.0 as i32)
.map_size(|v| if v <= 0 { 1 } else { v });
state.surface_state.set_geometry(
state.bounds.origin.x.0 as i32,
state.bounds.origin.y.0 as i32,
dp_size.width.0,
dp_size.height.0,
window_geometry.origin.x,
window_geometry.origin.y,
window_geometry.size.width,
window_geometry.size.height,
);
state

View File

@@ -40,6 +40,11 @@ impl WindowsWindowInner {
lparam: LPARAM,
) -> LRESULT {
let handled = match msg {
// eagerly activate the window, so calls to `active_window` will work correctly
WM_MOUSEACTIVATE => {
unsafe { SetActiveWindow(handle).log_err() };
None
}
WM_ACTIVATE => self.handle_activate_msg(wparam),
WM_CREATE => self.handle_create_msg(handle),
WM_MOVE => self.handle_move_msg(handle, lparam),

View File

@@ -659,7 +659,7 @@ impl Platform for WindowsPlatform {
if let Err(err) = result {
// ERROR_NOT_FOUND means the credential doesn't exist.
// Return Ok(None) to match macOS and Linux behavior.
if err.code().0 == ERROR_NOT_FOUND.0 as i32 {
if err.code() == ERROR_NOT_FOUND.to_hresult() {
return Ok(None);
}
return Err(err.into());

View File

@@ -4966,7 +4966,7 @@ impl<V: 'static> From<WindowHandle<V>> for AnyWindowHandle {
}
/// A handle to a window with any root view type, which can be downcast to a window with a specific root view type.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct AnyWindowHandle {
pub(crate) id: WindowId,
state_type: TypeId,

View File

@@ -1801,9 +1801,7 @@ impl Buffer {
self.syntax_map.lock().did_parse(syntax_snapshot);
self.request_autoindent(cx);
self.parse_status.0.send(ParseStatus::Idle).unwrap();
if self.text.version() != *self.tree_sitter_data.version() {
self.invalidate_tree_sitter_data(self.text.snapshot());
}
self.invalidate_tree_sitter_data(self.text.snapshot());
cx.emit(BufferEvent::Reparsed);
cx.notify();
}

View File

@@ -385,6 +385,8 @@ pub struct EditPredictionSettings {
pub copilot: CopilotSettings,
/// Settings specific to Codestral.
pub codestral: CodestralSettings,
/// Settings specific to Ollama.
pub ollama: OllamaSettings,
/// Whether edit predictions are enabled in the assistant panel.
/// This setting has no effect if globally disabled.
pub enabled_in_text_threads: bool,
@@ -430,6 +432,14 @@ pub struct CodestralSettings {
pub api_url: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct OllamaSettings {
/// Model to use for completions.
pub model: Option<String>,
/// Custom API URL to use for Ollama.
pub api_url: Option<String>,
}
impl AllLanguageSettings {
/// Returns the [`LanguageSettings`] for the language with the specified name.
pub fn language<'a>(
@@ -654,6 +664,12 @@ impl settings::Settings for AllLanguageSettings {
api_url: codestral.api_url,
};
let ollama = edit_predictions.ollama.unwrap();
let ollama_settings = OllamaSettings {
model: ollama.model,
api_url: ollama.api_url,
};
let enabled_in_text_threads = edit_predictions.enabled_in_text_threads.unwrap();
let mut file_types: FxHashMap<Arc<str>, (GlobSet, Vec<String>)> = FxHashMap::default();
@@ -692,6 +708,7 @@ impl settings::Settings for AllLanguageSettings {
mode: edit_predictions_mode,
copilot: copilot_settings,
codestral: codestral_settings,
ollama: ollama_settings,
enabled_in_text_threads,
},
defaults: default_language_settings,

View File

@@ -295,6 +295,23 @@ impl LspInstaller for TyLspAdapter {
})
}
async fn check_if_user_installed(
&self,
delegate: &dyn LspAdapterDelegate,
_: Option<Toolchain>,
_: &AsyncApp,
) -> Option<LanguageServerBinary> {
let Some(ty_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await else {
return None;
};
let env = delegate.shell_env().await;
Some(LanguageServerBinary {
path: ty_bin,
env: Some(env),
arguments: vec!["server".into()],
})
}
async fn fetch_server_binary(
&self,
latest_version: Self::BinaryVersion,

View File

@@ -355,7 +355,7 @@ impl LspAdapter for RustLspAdapter {
| lsp::CompletionTextEdit::Edit(lsp::TextEdit { new_text, .. }),
) = completion.text_edit.as_ref()
&& let Ok(mut snippet) = snippet::Snippet::parse(new_text)
&& !snippet.tabstops.is_empty()
&& snippet.tabstops.len() > 1
{
label = String::new();
@@ -421,7 +421,9 @@ impl LspAdapter for RustLspAdapter {
0..label.rfind('(').unwrap_or(completion.label.len()),
highlight_id,
));
} else if detail_left.is_none() {
} else if detail_left.is_none()
&& kind != Some(lsp::CompletionItemKind::SNIPPET)
{
return None;
}
}
@@ -1597,6 +1599,40 @@ mod tests {
))
);
// Postfix completion without actual tabstops (only implicit final $0)
// The label should use completion.label so it can be filtered by "ref"
let ref_completion = adapter
.label_for_completion(
&lsp::CompletionItem {
kind: Some(lsp::CompletionItemKind::SNIPPET),
label: "ref".to_string(),
filter_text: Some("ref".to_string()),
label_details: Some(CompletionItemLabelDetails {
detail: None,
description: Some("&expr".to_string()),
}),
detail: Some("&expr".to_string()),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range::default(),
new_text: "&String::new()".to_string(),
})),
..Default::default()
},
&language,
)
.await;
assert!(
ref_completion.is_some(),
"ref postfix completion should have a label"
);
let ref_label = ref_completion.unwrap();
let filter_text = &ref_label.text[ref_label.filter_range.clone()];
assert!(
filter_text.contains("ref"),
"filter range text '{filter_text}' should contain 'ref' for filtering to work",
);
// Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825)
let res = adapter
.label_for_completion(

View File

@@ -155,15 +155,15 @@ impl Model {
pub fn max_token_count(&self) -> u64 {
match self {
Self::CodestralLatest => 256000,
Self::MistralLargeLatest => 131000,
Self::MistralLargeLatest => 256000,
Self::MistralMediumLatest => 128000,
Self::MistralSmallLatest => 32000,
Self::MagistralMediumLatest => 40000,
Self::MagistralSmallLatest => 40000,
Self::MagistralMediumLatest => 128000,
Self::MagistralSmallLatest => 128000,
Self::OpenMistralNemo => 131000,
Self::OpenCodestralMamba => 256000,
Self::DevstralMediumLatest => 128000,
Self::DevstralSmallLatest => 262144,
Self::DevstralMediumLatest => 256000,
Self::DevstralSmallLatest => 256000,
Self::Pixtral12BLatest => 128000,
Self::PixtralLargeLatest => 128000,
Self::Custom { max_tokens, .. } => *max_tokens,

View File

@@ -17,9 +17,16 @@ schemars = ["dep:schemars"]
[dependencies]
anyhow.workspace = true
edit_prediction_context.workspace = true
edit_prediction_types.workspace = true
futures.workspace = true
gpui.workspace = true
http_client.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
schemars = { workspace = true, optional = true }
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
text.workspace = true

View File

@@ -1,4 +1,9 @@
use anyhow::{Context as _, Result};
mod ollama_edit_prediction_delegate;
pub use ollama_edit_prediction_delegate::OllamaEditPredictionDelegate;
use anyhow::{Context, Result};
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Request as HttpRequest};
use serde::{Deserialize, Serialize};

View File

@@ -0,0 +1,421 @@
use anyhow::{Context as _, Result};
use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions};
use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
use futures::AsyncReadExt;
use gpui::{App, Context, Entity, Task};
use http_client::HttpClient;
use language::{
Anchor, Buffer, BufferSnapshot, EditPreview, ToPoint, language_settings::all_language_settings,
};
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use serde::{Deserialize, Serialize};
use std::{
ops::Range,
sync::Arc,
time::{Duration, Instant},
};
use text::ToOffset;
use crate::{OLLAMA_API_URL, get_models};
pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150);
const EXCERPT_OPTIONS: EditPredictionExcerptOptions = EditPredictionExcerptOptions {
max_bytes: 1050,
min_bytes: 525,
target_before_cursor_over_total_bytes: 0.66,
};
pub const RECOMMENDED_EDIT_PREDICTION_MODELS: [&str; 4] = [
"qwen2.5-coder:3b-base",
"qwen2.5-coder:3b",
"qwen2.5-coder:7b-base",
"qwen2.5-coder:7b",
];
#[derive(Clone)]
struct CurrentCompletion {
snapshot: BufferSnapshot,
edits: Arc<[(Range<Anchor>, Arc<str>)]>,
edit_preview: EditPreview,
}
impl CurrentCompletion {
fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option<Vec<(Range<Anchor>, Arc<str>)>> {
edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits)
}
}
pub struct OllamaEditPredictionDelegate {
http_client: Arc<dyn HttpClient>,
pending_request: Option<Task<Result<()>>>,
current_completion: Option<CurrentCompletion>,
}
impl OllamaEditPredictionDelegate {
pub fn new(http_client: Arc<dyn HttpClient>) -> Self {
Self {
http_client,
pending_request: None,
current_completion: None,
}
}
pub fn is_available(cx: &App) -> bool {
let ollama_provider_id = LanguageModelProviderId::new("ollama");
LanguageModelRegistry::read_global(cx)
.provider(&ollama_provider_id)
.is_some_and(|provider| provider.is_authenticated(cx))
}
async fn fetch_completion(
http_client: Arc<dyn HttpClient>,
prompt: String,
suffix: String,
model: String,
api_url: String,
) -> Result<String> {
let start_time = Instant::now();
log::debug!("Ollama: Requesting completion (model: {})", model);
let fim_prompt = format_fim_prompt(&model, &prompt, &suffix);
let request = OllamaGenerateRequest {
model,
prompt: fim_prompt,
raw: true,
stream: false,
options: Some(OllamaGenerateOptions {
num_predict: Some(64),
temperature: Some(0.2),
stop: Some(get_stop_tokens()),
}),
};
let request_body = serde_json::to_string(&request)?;
log::debug!("Ollama: Sending FIM request");
let http_request = http_client::Request::builder()
.method(http_client::Method::POST)
.uri(format!("{}/api/generate", api_url))
.header("Content-Type", "application/json")
.body(http_client::AsyncBody::from(request_body))?;
let mut response = http_client.send(http_request).await?;
let status = response.status();
log::debug!("Ollama: Response status: {}", status);
if !status.is_success() {
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
return Err(anyhow::anyhow!("Ollama API error: {} - {}", status, body));
}
let mut body = String::new();
response.body_mut().read_to_string(&mut body).await?;
let ollama_response: OllamaGenerateResponse =
serde_json::from_str(&body).context("Failed to parse Ollama response")?;
let elapsed = start_time.elapsed();
log::debug!(
"Ollama: Completion received ({:.2}s)",
elapsed.as_secs_f64()
);
let completion = clean_completion(&ollama_response.response);
Ok(completion)
}
}
impl EditPredictionDelegate for OllamaEditPredictionDelegate {
fn name() -> &'static str {
"ollama"
}
fn display_name() -> &'static str {
"Ollama"
}
fn show_predictions_in_menu() -> bool {
true
}
fn is_enabled(&self, _buffer: &Entity<Buffer>, _cursor_position: Anchor, cx: &App) -> bool {
Self::is_available(cx)
}
fn is_refreshing(&self, _cx: &App) -> bool {
self.pending_request.is_some()
}
fn refresh(
&mut self,
buffer: Entity<Buffer>,
cursor_position: Anchor,
debounce: bool,
cx: &mut Context<Self>,
) {
log::debug!("Ollama: Refresh called (debounce: {})", debounce);
let snapshot = buffer.read(cx).snapshot();
if let Some(current_completion) = self.current_completion.as_ref() {
if current_completion.interpolate(&snapshot).is_some() {
return;
}
}
let http_client = self.http_client.clone();
let settings = all_language_settings(None, cx);
let configured_model = settings.edit_predictions.ollama.model.clone();
let api_url = settings
.edit_predictions
.ollama
.api_url
.clone()
.unwrap_or_else(|| OLLAMA_API_URL.to_string());
self.pending_request = Some(cx.spawn(async move |this, cx| {
if debounce {
log::debug!("Ollama: Debouncing for {:?}", DEBOUNCE_TIMEOUT);
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
}
let model = if let Some(model) = configured_model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
{
model.to_string()
} else {
let local_models = get_models(http_client.as_ref(), &api_url, None).await?;
let available_model_names = local_models.iter().map(|model| model.name.as_str());
match pick_recommended_edit_prediction_model(available_model_names) {
Some(recommended) => recommended.to_string(),
None => {
log::debug!(
"Ollama: No model configured and no recommended local model found; skipping edit prediction"
);
this.update(cx, |this, cx| {
this.pending_request = None;
cx.notify();
})?;
return Ok(());
}
}
};
let cursor_offset = cursor_position.to_offset(&snapshot);
let cursor_point = cursor_offset.to_point(&snapshot);
let excerpt = EditPredictionExcerpt::select_from_buffer(
cursor_point,
&snapshot,
&EXCERPT_OPTIONS,
)
.context("Line containing cursor doesn't fit in excerpt max bytes")?;
let excerpt_text = excerpt.text(&snapshot);
let cursor_within_excerpt = cursor_offset
.saturating_sub(excerpt.range.start)
.min(excerpt_text.body.len());
let prompt = excerpt_text.body[..cursor_within_excerpt].to_string();
let suffix = excerpt_text.body[cursor_within_excerpt..].to_string();
let completion_text =
match Self::fetch_completion(http_client, prompt, suffix, model, api_url).await {
Ok(completion) => completion,
Err(e) => {
log::error!("Ollama: Failed to fetch completion: {}", e);
this.update(cx, |this, cx| {
this.pending_request = None;
cx.notify();
})?;
return Err(e);
}
};
if completion_text.trim().is_empty() {
log::debug!("Ollama: Completion was empty after trimming; ignoring");
this.update(cx, |this, cx| {
this.pending_request = None;
cx.notify();
})?;
return Ok(());
}
let edits: Arc<[(Range<Anchor>, Arc<str>)]> = buffer.read_with(cx, |buffer, _cx| {
// Use anchor_after (Right bias) so the cursor stays before the completion text,
// not at the end of it. This matches how Copilot handles edit predictions.
let position = buffer.anchor_after(cursor_offset);
vec![(position..position, completion_text.into())].into()
})?;
let edit_preview = buffer
.read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx))?
.await;
this.update(cx, |this, cx| {
this.current_completion = Some(CurrentCompletion {
snapshot,
edits,
edit_preview,
});
this.pending_request = None;
cx.notify();
})?;
Ok(())
}));
}
fn accept(&mut self, _cx: &mut Context<Self>) {
log::debug!("Ollama: Completion accepted");
self.pending_request = None;
self.current_completion = None;
}
fn discard(&mut self, _cx: &mut Context<Self>) {
log::debug!("Ollama: Completion discarded");
self.pending_request = None;
self.current_completion = None;
}
fn suggest(
&mut self,
buffer: &Entity<Buffer>,
_cursor_position: Anchor,
cx: &mut Context<Self>,
) -> Option<EditPrediction> {
let current_completion = self.current_completion.as_ref()?;
let buffer = buffer.read(cx);
let edits = current_completion.interpolate(&buffer.snapshot())?;
if edits.is_empty() {
return None;
}
Some(EditPrediction::Local {
id: None,
edits,
edit_preview: Some(current_completion.edit_preview.clone()),
})
}
}
fn format_fim_prompt(model: &str, prefix: &str, suffix: &str) -> String {
let model_base = model.split(':').next().unwrap_or(model);
match model_base {
"codellama" | "code-llama" => {
format!("<PRE> {prefix} <SUF>{suffix} <MID>")
}
"starcoder" | "starcoder2" | "starcoderbase" => {
format!("<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>")
}
"deepseek-coder" | "deepseek-coder-v2" => {
// DeepSeek uses special Unicode characters for FIM tokens
format!("<fim▁begin>{prefix}<fim▁hole>{suffix}<fim▁end>")
}
"qwen2.5-coder" | "qwen-coder" | "qwen" => {
format!("<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>")
}
"codegemma" => {
format!("<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>")
}
"codestral" | "mistral" => {
format!("[SUFFIX]{suffix}[PREFIX]{prefix}")
}
"glm" | "glm-4" | "glm-4.5" => {
format!("<|code_prefix|>{prefix}<|code_suffix|>{suffix}<|code_middle|>")
}
_ => {
format!("<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>")
}
}
}
fn get_stop_tokens() -> Vec<String> {
vec![
"<|endoftext|>".to_string(),
"<|file_separator|>".to_string(),
"<|fim_pad|>".to_string(),
"<|fim_prefix|>".to_string(),
"<|fim_middle|>".to_string(),
"<|fim_suffix|>".to_string(),
"<fim_prefix>".to_string(),
"<fim_middle>".to_string(),
"<fim_suffix>".to_string(),
"<PRE>".to_string(),
"<SUF>".to_string(),
"<MID>".to_string(),
"[PREFIX]".to_string(),
"[SUFFIX]".to_string(),
]
}
fn clean_completion(response: &str) -> String {
let mut result = response.to_string();
let end_tokens = [
"<|endoftext|>",
"<|file_separator|>",
"<|fim_pad|>",
"<|fim_prefix|>",
"<|fim_middle|>",
"<|fim_suffix|>",
"<fim_prefix>",
"<fim_middle>",
"<fim_suffix>",
"<PRE>",
"<SUF>",
"<MID>",
"[PREFIX]",
"[SUFFIX]",
];
for token in &end_tokens {
if let Some(pos) = result.find(token) {
result.truncate(pos);
}
}
result
}
#[derive(Debug, Serialize)]
struct OllamaGenerateRequest {
model: String,
prompt: String,
raw: bool,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<OllamaGenerateOptions>,
}
#[derive(Debug, Serialize)]
struct OllamaGenerateOptions {
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
stop: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct OllamaGenerateResponse {
response: String,
}
pub fn pick_recommended_edit_prediction_model<'a>(
available_models: impl IntoIterator<Item = &'a str>,
) -> Option<&'static str> {
let available: std::collections::HashSet<&str> = available_models.into_iter().collect();
RECOMMENDED_EDIT_PREDICTION_MODELS
.into_iter()
.find(|recommended| available.contains(recommended))
}

View File

@@ -460,7 +460,7 @@ impl AgentServerStore {
.gemini
.as_ref()
.and_then(|settings| settings.ignore_system_version)
.unwrap_or(false),
.unwrap_or(true),
}),
);
self.external_agents.insert(

View File

@@ -1672,59 +1672,6 @@ impl GitStore {
}
}
fn mark_entries_pending_by_project_paths(
&mut self,
project_paths: &[ProjectPath],
stage: bool,
cx: &mut Context<Self>,
) {
let buffer_store = &self.buffer_store;
for project_path in project_paths {
let Some(buffer) = buffer_store.read(cx).get_by_path(project_path) else {
continue;
};
let buffer_id = buffer.read(cx).remote_id();
let Some(diff_state) = self.diffs.get(&buffer_id) else {
continue;
};
diff_state.update(cx, |diff_state, cx| {
let Some(uncommitted_diff) = diff_state.uncommitted_diff() else {
return;
};
let buffer_snapshot = buffer.read(cx).text_snapshot();
let file_exists = buffer
.read(cx)
.file()
.is_some_and(|file| file.disk_state().exists());
let all_hunks: Vec<_> = uncommitted_diff
.read(cx)
.hunks_intersecting_range(
text::Anchor::MIN..text::Anchor::MAX,
&buffer_snapshot,
cx,
)
.collect();
if !all_hunks.is_empty() {
uncommitted_diff.update(cx, |diff, cx| {
diff.stage_or_unstage_hunks(
stage,
&all_hunks,
&buffer_snapshot,
file_exists,
cx,
);
});
}
});
}
}
pub fn git_clone(
&self,
repo: String,
@@ -4253,28 +4200,6 @@ impl Repository {
save_futures
}
fn mark_entries_pending_for_stage(
&self,
entries: &[RepoPath],
stage: bool,
cx: &mut Context<Self>,
) {
let Some(git_store) = self.git_store() else {
return;
};
let mut project_paths = Vec::new();
for repo_path in entries {
if let Some(project_path) = self.repo_path_to_project_path(repo_path, cx) {
project_paths.push(project_path);
}
}
git_store.update(cx, move |git_store, cx| {
git_store.mark_entries_pending_by_project_paths(&project_paths, stage, cx);
});
}
pub fn stage_entries(
&mut self,
entries: Vec<RepoPath>,
@@ -4283,9 +4208,6 @@ impl Repository {
if entries.is_empty() {
return Task::ready(Ok(()));
}
self.mark_entries_pending_for_stage(&entries, true, cx);
let id = self.id;
let save_tasks = self.save_buffers(&entries, cx);
let paths = entries
@@ -4351,9 +4273,6 @@ impl Repository {
if entries.is_empty() {
return Task::ready(Ok(()));
}
self.mark_entries_pending_for_stage(&entries, false, cx);
let id = self.id;
let save_tasks = self.save_buffers(&entries, cx);
let paths = entries

View File

@@ -76,6 +76,7 @@ pub enum EditPredictionProvider {
Supermaven,
Zed,
Codestral,
Ollama,
Experimental(&'static str),
}
@@ -96,6 +97,7 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
Supermaven,
Zed,
Codestral,
Ollama,
Experimental(String),
}
@@ -105,6 +107,7 @@ impl<'de> Deserialize<'de> for EditPredictionProvider {
Content::Supermaven => EditPredictionProvider::Supermaven,
Content::Zed => EditPredictionProvider::Zed,
Content::Codestral => EditPredictionProvider::Codestral,
Content::Ollama => EditPredictionProvider::Ollama,
Content::Experimental(name)
if name == EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME =>
{
@@ -144,6 +147,7 @@ impl EditPredictionProvider {
| EditPredictionProvider::Copilot
| EditPredictionProvider::Supermaven
| EditPredictionProvider::Codestral
| EditPredictionProvider::Ollama
| EditPredictionProvider::Experimental(_) => false,
}
}
@@ -164,6 +168,8 @@ pub struct EditPredictionSettingsContent {
pub copilot: Option<CopilotSettingsContent>,
/// Settings specific to Codestral.
pub codestral: Option<CodestralSettingsContent>,
/// Settings specific to Ollama.
pub ollama: Option<OllamaEditPredictionSettingsContent>,
/// Whether edit predictions are enabled in the assistant prompt editor.
/// This has no effect if globally disabled.
pub enabled_in_text_threads: Option<bool>,
@@ -203,6 +209,19 @@ pub struct CodestralSettingsContent {
pub api_url: Option<String>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct OllamaEditPredictionSettingsContent {
/// Model to use for completions.
///
/// Default: none
pub model: Option<String>,
/// Api URL to use for completions.
///
/// Default: "http://localhost:11434"
pub api_url: Option<String>,
}
/// The mode in which edit predictions should be displayed.
#[derive(
Copy,

View File

@@ -8,6 +8,9 @@ use gpui::{Entity, ScrollHandle, prelude::*};
use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key};
use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*};
const OLLAMA_API_URL_PLACEHOLDER: &str = "http://localhost:11434";
const OLLAMA_MODEL_PLACEHOLDER: &str = "qwen2.5-coder:3b-base";
use crate::{
SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
components::{SettingsInputField, SettingsSectionHeader},
@@ -82,6 +85,7 @@ impl Render for EditPredictionSetupPage {
)
.into_any_element(),
),
Some(render_ollama_provider(settings_window.clone(), window, cx).into_any_element()),
];
div()
@@ -236,6 +240,107 @@ fn render_api_key_provider(
})
}
fn render_ollama_provider(
settings_window: Entity<SettingsWindow>,
window: &mut Window,
cx: &mut Context<EditPredictionSetupPage>,
) -> impl IntoElement {
let ollama_settings = ollama_settings();
let additional_fields = settings_window.update(cx, |settings_window, cx| {
settings_window
.render_sub_page_items_section(ollama_settings.iter().enumerate(), None, window, cx)
.into_any_element()
});
v_flex()
.id("ollama")
.min_w_0()
.pt_8()
.gap_1p5()
.child(
SettingsSectionHeader::new("Ollama")
.icon(IconName::ZedPredict)
.no_padding(true),
)
.child(
Label::new("Configure the local Ollama server and model used for edit predictions.")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(additional_fields)
}
fn ollama_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
title: "API URL",
description: "The base URL of your Ollama server.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.api_url
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.api_url = value;
},
json_path: Some("edit_predictions.ollama.api_url"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_API_URL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Model",
description: "The Ollama model to use for edit predictions.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.ollama
.as_ref()?
.model
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.ollama
.get_or_insert_default()
.model = value;
},
json_path: Some("edit_predictions.ollama.model"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(OLLAMA_MODEL_PLACEHOLDER),
..Default::default()
})),
files: USER,
}),
])
}
fn codestral_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {

View File

@@ -790,8 +790,7 @@ impl TerminalPanel {
}
pane.update(cx, |pane, cx| {
let focus = pane.has_focus(window, cx)
|| matches!(reveal_strategy, RevealStrategy::Always);
let focus = matches!(reveal_strategy, RevealStrategy::Always);
pane.add_item(terminal_view, true, focus, None, window, cx);
});
@@ -853,8 +852,7 @@ impl TerminalPanel {
}
pane.update(cx, |pane, cx| {
let focus = pane.has_focus(window, cx)
|| matches!(reveal_strategy, RevealStrategy::Always);
let focus = matches!(reveal_strategy, RevealStrategy::Always);
pane.add_item(terminal_view, true, focus, None, window, cx);
});
@@ -1171,64 +1169,67 @@ pub fn new_terminal_pane(
let source = tab.pane.clone();
let item_id_to_move = item.item_id();
let Ok(new_split_pane) = pane
.drag_split_direction()
.map(|split_direction| {
drop_closure_terminal_panel.update(cx, |terminal_panel, cx| {
let is_zoomed = if terminal_panel.active_pane == this_pane {
pane.is_zoomed()
} else {
terminal_panel.active_pane.read(cx).is_zoomed()
};
let new_pane = new_terminal_pane(
workspace.clone(),
project.clone(),
is_zoomed,
window,
cx,
);
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
terminal_panel.center.split(
&this_pane,
&new_pane,
split_direction,
cx,
)?;
anyhow::Ok(new_pane)
})
})
.transpose()
else {
return ControlFlow::Break(());
// If no split direction, let the regular pane drop handler take care of it
let Some(split_direction) = pane.drag_split_direction() else {
return ControlFlow::Continue(());
};
match new_split_pane.transpose() {
// Source pane may be the one currently updated, so defer the move.
Ok(Some(new_pane)) => cx
.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| {
move_item(
&source,
&new_pane,
item_id_to_move,
new_pane.read(cx).active_item_index(),
true,
window,
cx,
// Gather data synchronously before deferring
let is_zoomed = drop_closure_terminal_panel
.upgrade()
.map(|terminal_panel| {
let terminal_panel = terminal_panel.read(cx);
if terminal_panel.active_pane == this_pane {
pane.is_zoomed()
} else {
terminal_panel.active_pane.read(cx).is_zoomed()
}
})
.unwrap_or(false);
let workspace = workspace.clone();
let terminal_panel = drop_closure_terminal_panel.clone();
// Defer the split operation to avoid re-entrancy panic.
// The pane may be the one currently being updated, so we cannot
// call mark_positions (via split) synchronously.
cx.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| {
let Ok(new_pane) =
terminal_panel.update(cx, |terminal_panel, cx| {
let new_pane = new_terminal_pane(
workspace, project, is_zoomed, window, cx,
);
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
terminal_panel.center.split(
&this_pane,
&new_pane,
split_direction,
cx,
)?;
anyhow::Ok(new_pane)
})
.ok();
})
.detach(),
// If we drop into existing pane or current pane,
// regular pane drop handler will take care of it,
// using the right tab index for the operation.
Ok(None) => return ControlFlow::Continue(()),
err @ Err(_) => {
err.log_err();
return ControlFlow::Break(());
}
};
else {
return;
};
let Some(new_pane) = new_pane.log_err() else {
return;
};
move_item(
&source,
&new_pane,
item_id_to_move,
new_pane.read(cx).active_item_index(),
true,
window,
cx,
);
})
.ok();
})
.detach();
} else if let Some(project_path) = item.project_path(cx)
&& let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx)
{

View File

@@ -8,8 +8,8 @@ mod terminal_slash_command;
use assistant_slash_command::SlashCommandRegistry;
use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
use gpui::{
Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
};
use persistence::TERMINAL_DB;
@@ -687,10 +687,30 @@ impl TerminalView {
///Attempt to paste the clipboard into the terminal
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
self.terminal
.update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
if clipboard.entries().iter().any(|entry| match entry {
ClipboardEntry::Image(image) => !image.bytes.is_empty(),
_ => false,
}) {
self.forward_ctrl_v(cx);
return;
}
if let Some(text) = clipboard.text() {
self.terminal
.update(cx, |terminal, _cx| terminal.paste(&text));
}
}
/// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
/// and attach images using their native workflows.
fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
self.terminal.update(cx, |term, _| {
term.input(vec![0x16]);
});
}
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {

View File

@@ -121,7 +121,7 @@ impl RenderOnce for Callout {
Severity::Info => (
IconName::Info,
Color::Muted,
cx.theme().colors().panel_background.opacity(0.),
cx.theme().status().info_background.opacity(0.1),
),
Severity::Success => (
IconName::Check,

View File

@@ -886,8 +886,12 @@ impl<T: Item> ItemHandle for Entity<T> {
// Only trigger autosave if focus has truly left the item.
// If focus is still within the item's hierarchy (e.g., moved to a context menu),
// don't trigger autosave to avoid unwanted formatting and cursor jumps.
// Also skip autosave if focus moved to a modal (e.g., command palette),
// since the user is still interacting with the workspace.
let focus_handle = item.item_focus_handle(cx);
if !focus_handle.contains_focused(window, cx) {
if !focus_handle.contains_focused(window, cx)
&& !workspace.has_active_modal(window, cx)
{
Pane::autosave_item(&item, workspace.project.clone(), window, cx)
.detach_and_log_err(cx);
}

View File

@@ -193,6 +193,12 @@ impl Render for ModalLayer {
background.fade_out(0.2);
this.bg(background)
})
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.hide_modal(window, cx);
}),
)
.child(
v_flex()
.h(px(0.0))

View File

@@ -3296,4 +3296,53 @@ mod tests {
assert_eq!(workspace.center_group, new_workspace.center_group);
}
#[gpui::test]
async fn test_empty_workspace_window_bounds() {
zlog::init_test();
let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await;
let id = db.next_id().await.unwrap();
// Create a workspace with empty paths (empty workspace)
let empty_paths: &[&str] = &[];
let display_uuid = Uuid::new_v4();
let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds {
origin: point(px(100.0), px(200.0)),
size: size(px(800.0), px(600.0)),
}));
let workspace = SerializedWorkspace {
id,
paths: PathList::new(empty_paths),
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: None,
display: None,
docks: Default::default(),
breakpoints: Default::default(),
centered_layout: false,
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
// Save the workspace (this creates the record with empty paths)
db.save_workspace(workspace.clone()).await;
// Save window bounds separately (as the actual code does via set_window_open_status)
db.set_window_open_status(id, window_bounds, display_uuid)
.await
.unwrap();
// Retrieve it using empty paths
let retrieved = db.workspace_for_roots(empty_paths).unwrap();
// Verify window bounds were persisted
assert_eq!(retrieved.id, id);
assert!(retrieved.window_bounds.is_some());
assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0);
assert!(retrieved.display.is_some());
assert_eq!(retrieved.display.unwrap(), display_uuid);
}
}

View File

@@ -1748,26 +1748,18 @@ impl Workspace {
window
} else {
let window_bounds_override = window_bounds_env_override();
let is_empty_workspace = project_paths.is_empty();
let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
(Some(WindowBounds::Windowed(bounds)), None)
} else if let Some(workspace) = serialized_workspace.as_ref() {
} else if let Some(workspace) = serialized_workspace.as_ref()
&& let Some(display) = workspace.display
&& let Some(bounds) = workspace.window_bounds.as_ref()
{
// Reopening an existing workspace - restore its saved bounds
if let (Some(display), Some(bounds)) =
(workspace.display, workspace.window_bounds.as_ref())
{
(Some(bounds.0), Some(display))
} else {
(None, None)
}
} else if is_empty_workspace {
// Empty workspace - try to restore the last known no-project window bounds
if let Some((display, bounds)) = persistence::read_default_window_bounds() {
(Some(bounds), Some(display))
} else {
(None, None)
}
(Some(bounds.0), Some(display))
} else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
// New or empty workspace - use the last known window bounds
(Some(bounds), Some(display))
} else {
// New window - let GPUI's default_bounds() handle cascading
(None, None)
@@ -5673,12 +5665,24 @@ impl Workspace {
persistence::DB.save_workspace(serialized_workspace).await;
})
}
WorkspaceLocation::DetachFromSession => window.spawn(cx, async move |_| {
persistence::DB
.set_session_id(database_id, None)
.await
.log_err();
}),
WorkspaceLocation::DetachFromSession => {
let window_bounds = SerializedWindowBounds(window.window_bounds());
let display = window.display(cx).and_then(|d| d.uuid().ok());
window.spawn(cx, async move |_| {
persistence::DB
.set_window_open_status(
database_id,
window_bounds,
display.unwrap_or_default(),
)
.await
.log_err();
persistence::DB
.set_session_id(database_id, None)
.await
.log_err();
})
}
WorkspaceLocation::None => Task::ready(()),
}
}

View File

@@ -42,6 +42,7 @@ cli.workspace = true
client.workspace = true
codestral.workspace = true
collab_ui.workspace = true
ollama.workspace = true
collections.workspace = true
command_palette.workspace = true
component.workspace = true

Binary file not shown.

View File

@@ -8,6 +8,7 @@ use feature_flags::FeatureFlagAppExt;
use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity};
use language::language_settings::{EditPredictionProvider, all_language_settings};
use language_models::MistralLanguageModelProvider;
use ollama::OllamaEditPredictionDelegate;
use settings::{
EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
@@ -186,6 +187,11 @@ fn assign_edit_prediction_provider(
let provider = cx.new(|_| CodestralEditPredictionDelegate::new(http_client));
editor.set_edit_prediction_provider(Some(provider), window, cx);
}
EditPredictionProvider::Ollama => {
let http_client = client.http_client();
let provider = cx.new(|_| OllamaEditPredictionDelegate::new(http_client));
editor.set_edit_prediction_provider(Some(provider), window, cx);
}
value @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => {
let ep_store = edit_prediction::EditPredictionStore::global(client, &user_store, cx);

View File

@@ -15,6 +15,10 @@ When there is an appropriate language server available, Zed will provide complet
You can manually trigger completions with `ctrl-space` or by triggering the `editor::ShowCompletions` action from the command palette.
> Note: Using `ctrl-space` in Zed requires disabling the macOS global shortcut.
> Open **System Settings** > **Keyboard** > **Keyboard Shortcut**s >
> **Input Sources** and uncheck **Select the previous input source**.
For more information, see:
- [Configuring Supported Languages](./configuring-languages.md)

View File

@@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml
popd
echo "Bundled ${app_path}"
# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns.
# We use the app icon as a placeholder document icon for now.
document_icon_source="crates/zed/resources/Document.icns"
document_icon_target="${app_path}/Contents/Resources/Document.icns"
if [[ -f "${document_icon_source}" ]]; then
mkdir -p "$(dirname "${document_icon_target}")"
cp "${document_icon_source}" "${document_icon_target}"
else
echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder."
fi
if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
can_code_sign=true

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
script/verify-macos-document-icon /path/to/Zed.app
Verifies that the given macOS app bundle's Info.plist references a document icon
named "Document" and that the corresponding icon file exists in the bundle.
Specifically checks:
- CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document"
- Contents/Resources/Document.icns exists
Exit codes:
0 - success
1 - verification failed
2 - invalid usage / missing prerequisites
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
usage >&2
exit 2
fi
app_path="$1"
if [[ ! -d "${app_path}" ]]; then
fail "app bundle not found: ${app_path}"
fi
info_plist="${app_path}/Contents/Info.plist"
if [[ ! -f "${info_plist}" ]]; then
fail "missing Info.plist: ${info_plist}"
fi
if ! command -v plutil >/dev/null 2>&1; then
fail "plutil not found (required on macOS to read Info.plist)"
fi
# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode.
info_json="$(plutil -convert json -o - "${info_plist}")"
# Check that CFBundleDocumentTypes exists and that at least one entry references "Document".
# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all.
# If python3 isn't available, fall back to a simpler grep-based check.
has_document_icon_ref="false"
if command -v python3 >/dev/null 2>&1; then
has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")"
else
# This is a best-effort fallback. It may produce false negatives if the JSON formatting differs.
if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then
has_document_icon_ref="true"
fi
fi
if [[ "${has_document_icon_ref}" != "true" ]]; then
echo "Verification failed for: ${app_path}" >&2
echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2
echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2
exit 1
fi
document_icon_path="${app_path}/Contents/Resources/Document.icns"
if [[ ! -f "${document_icon_path}" ]]; then
echo "Verification failed for: ${app_path}" >&2
echo "Expected document icon to exist: ${document_icon_path}" >&2
echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2
exit 1
fi
echo "OK: ${app_path}"
echo " - Info.plist references CFBundleTypeIconFile \"Document\""
echo " - Found ${document_icon_path}"

View File

@@ -45,11 +45,15 @@ pub(crate) fn run_tests() -> Workflow {
&should_run_tests,
]);
let check_style = check_style();
let run_tests_linux = run_platform_tests(Platform::Linux);
let call_autofix = call_autofix(&check_style, &run_tests_linux);
let mut jobs = vec![
orchestrate,
check_style(),
check_style,
should_run_tests.guard(run_platform_tests(Platform::Windows)),
should_run_tests.guard(run_platform_tests(Platform::Linux)),
should_run_tests.guard(run_tests_linux),
should_run_tests.guard(run_platform_tests(Platform::Mac)),
should_run_tests.guard(doctests()),
should_run_tests.guard(check_workspace_binaries()),
@@ -106,6 +110,7 @@ pub(crate) fn run_tests() -> Workflow {
workflow
})
.add_job(tests_pass.name, tests_pass.job)
.add_job(call_autofix.name, call_autofix.job)
}
// Generates a bash script that checks changed files against regex patterns
@@ -221,6 +226,8 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
named::job(job)
}
pub const STYLE_FAILED_OUTPUT: &str = "style_failed";
fn check_style() -> NamedJob {
fn check_for_typos() -> Step<Use> {
named::uses(
@@ -236,15 +243,58 @@ fn check_style() -> NamedJob {
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(steps::setup_pnpm())
.add_step(steps::script("./script/prettier"))
.add_step(steps::prettier())
.add_step(steps::cargo_fmt())
.add_step(steps::trigger_autofix(false))
.add_step(steps::record_style_failure())
.add_step(steps::script("./script/check-todos"))
.add_step(steps::script("./script/check-keymaps"))
.add_step(check_for_typos()),
.add_step(check_for_typos())
.outputs([(
STYLE_FAILED_OUTPUT.to_owned(),
format!(
"${{{{ steps.{}.outputs.failed == 'true' }}}}",
steps::RECORD_STYLE_FAILURE_STEP_ID
),
)]),
)
}
fn call_autofix(check_style: &NamedJob, run_tests_linux: &NamedJob) -> NamedJob {
fn dispatch_autofix(run_tests_linux_name: &str) -> Step<Run> {
let clippy_failed_expr = format!(
"needs.{}.outputs.{} == 'true'",
run_tests_linux_name, CLIPPY_FAILED_OUTPUT
);
named::bash(format!(
"gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy=${{{{ {} }}}}",
clippy_failed_expr
))
.add_env(("GITHUB_TOKEN", "${{ steps.get-app-token.outputs.token }}"))
}
let style_failed_expr = format!(
"needs.{}.outputs.{} == 'true'",
check_style.name, STYLE_FAILED_OUTPUT
);
let clippy_failed_expr = format!(
"needs.{}.outputs.{} == 'true'",
run_tests_linux.name, CLIPPY_FAILED_OUTPUT
);
let (authenticate, _token) = steps::authenticate_as_zippy();
let job = Job::default()
.runs_on(runners::LINUX_SMALL)
.cond(Expression::new(format!(
"always() && ({} || {}) && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'",
style_failed_expr, clippy_failed_expr
)))
.needs(vec![check_style.name.clone(), run_tests_linux.name.clone()])
.add_step(authenticate)
.add_step(dispatch_autofix(&run_tests_linux.name));
named::job(job)
}
fn check_dependencies() -> NamedJob {
fn install_cargo_machete() -> Step<Use> {
named::uses(
@@ -305,6 +355,8 @@ fn check_workspace_binaries() -> NamedJob {
)
}
pub const CLIPPY_FAILED_OUTPUT: &str = "clippy_failed";
pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
let runner = match platform {
Platform::Windows => runners::WINDOWS_DEFAULT,
@@ -327,12 +379,23 @@ pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
.add_step(steps::setup_node())
.add_step(steps::clippy(platform))
.when(platform == Platform::Linux, |job| {
job.add_step(steps::trigger_autofix(true))
.add_step(steps::cargo_install_nextest())
job.add_step(steps::record_clippy_failure())
})
.when(platform == Platform::Linux, |job| {
job.add_step(steps::cargo_install_nextest())
})
.add_step(steps::clear_target_dir_if_large(platform))
.add_step(steps::cargo_nextest(platform))
.add_step(steps::cleanup_cargo_config(platform)),
.add_step(steps::cleanup_cargo_config(platform))
.when(platform == Platform::Linux, |job| {
job.outputs([(
CLIPPY_FAILED_OUTPUT.to_owned(),
format!(
"${{{{ steps.{}.outputs.failed == 'true' }}}}",
steps::RECORD_CLIPPY_FAILURE_STEP_ID
),
)])
}),
}
}

View File

@@ -54,8 +54,25 @@ pub fn setup_sentry() -> Step<Use> {
.add_with(("token", vars::SENTRY_AUTH_TOKEN))
}
pub const PRETTIER_STEP_ID: &str = "prettier";
pub const CARGO_FMT_STEP_ID: &str = "cargo_fmt";
pub const RECORD_STYLE_FAILURE_STEP_ID: &str = "record_style_failure";
pub fn prettier() -> Step<Run> {
named::bash("./script/prettier").id(PRETTIER_STEP_ID)
}
pub fn cargo_fmt() -> Step<Run> {
named::bash("cargo fmt --all -- --check")
named::bash("cargo fmt --all -- --check").id(CARGO_FMT_STEP_ID)
}
pub fn record_style_failure() -> Step<Run> {
named::bash(format!(
"echo \"failed=${{{{ steps.{}.outcome == 'failure' || steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"",
PRETTIER_STEP_ID, CARGO_FMT_STEP_ID
))
.id(RECORD_STYLE_FAILURE_STEP_ID)
.if_condition(Expression::new("always()"))
}
pub fn cargo_install_nextest() -> Step<Use> {
@@ -101,13 +118,25 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
}
}
pub const CLIPPY_STEP_ID: &str = "clippy";
pub const RECORD_CLIPPY_FAILURE_STEP_ID: &str = "record_clippy_failure";
pub fn clippy(platform: Platform) -> Step<Run> {
match platform {
Platform::Windows => named::pwsh("./script/clippy.ps1"),
_ => named::bash("./script/clippy"),
Platform::Windows => named::pwsh("./script/clippy.ps1").id(CLIPPY_STEP_ID),
_ => named::bash("./script/clippy").id(CLIPPY_STEP_ID),
}
}
pub fn record_clippy_failure() -> Step<Run> {
named::bash(format!(
"echo \"failed=${{{{ steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"",
CLIPPY_STEP_ID
))
.id(RECORD_CLIPPY_FAILURE_STEP_ID)
.if_condition(Expression::new("always()"))
}
pub fn cache_rust_dependencies_namespace() -> Step<Use> {
named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust"))
}
@@ -345,16 +374,6 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
))
}
pub fn trigger_autofix(run_clippy: bool) -> Step<Run> {
named::bash(format!(
"gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy={run_clippy}"
))
.if_condition(Expression::new(
"failure() && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'",
))
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
}
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",