Compare commits

...

32 Commits

Author SHA1 Message Date
Joseph T. Lyons
63cab3ccc8 v0.203.x stable 2025-09-10 10:08:22 -04:00
Anthony Eid
8aa6a94610 onboarding: Add telemetry to Basics page (#37502)
- Welcome Keymap Changed
- Welcome Theme Changed
- Welcome Theme mode Changed
- Welcome Page Telemetry Diagnostics Toggled
- Welcome Page Telemetry Metrics Toggled
- Welcome Vim Mode Toggled
- Welcome Keymap Changed
- Welcome Sign In Clicked

cc: @katie-z-geer

Release Notes:

- N/A
2025-09-10 00:07:49 -04:00
Conrad Irwin
63860fc5c6 Merge conflicts 2025-09-09 16:17:14 -06:00
Zed Bot
996f9cad68 Bump to 0.203.4 for @ConradIrwin 2025-09-09 22:11:42 +00:00
Agus Zubiaga
21aa3d5cd7 acp: Ensure connection subprocess gets killed on drop (#37858)
It appears that in macOS, the `AcpConnection._wait_task` doesn't always
get dropped when quitting the app. In these cases, the subprocess would
be kept alive because we move the `child` into it.

Instead, we will now explicitly kill it when `AcpConnection` is dropped.
It's ok to do this because when the connection is dropped, the thread is
also dropped, so there's no need to report the exit status to it.

Closes #37741

Release Notes:

- Claude Code: Fix subprocess leak on app quit
2025-09-09 16:08:28 -06:00
Conrad Irwin
c5d36e05f8 Allow unauthenticated commit models to show (#37857)
Closes #37462
Closes #37814

Release Notes:

- Fixed a bug where the commit generation message would not always show
2025-09-09 12:50:22 -06:00
Conrad Irwin
b63c715a53 Only reject agent actions, don't restore checkpoint on revert (#37801)
Updates #37623

Release Notes:

- Changed the behaviour when editing an old message in a native agent
thread.
Prior to this, it would automatically restore the checkpoint (which
could
lead to a surprising amount of work being discarded). Now it will just
reject
any unaccepted agent edits, and you can use the "restore checkpoint"
button
  for the original behavior.
2025-09-08 20:19:10 -06:00
Peter Tripp
d287b8bbf7 zed 0.203.3 2025-09-08 14:33:55 -04:00
Lukas Wirth
bbbfa10fe1 project: Consider all worktrees for activation script search (#37764)
Should fix https://github.com/zed-industries/zed/issues/37734

Release Notes:

- Fixed venv not always activating correctly
2025-09-08 19:38:57 +02:00
Cole Miller
0c1ad95e8c acp: Pass project environment to external agent servers (#37568)
Closes #37469 

Release Notes:

- agent: The project shell environment is now passed to external agent
processes.

Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Co-authored-by: Nia Espera <nia-e@haecceity.cc>
2025-09-08 13:29:46 -04:00
张小白
29b7863403 macos: Fix menu bar flickering (#37707)
Closes #37526

Release Notes:

- Fixed menu bar flickering when using some IMEs on macOS.
2025-09-08 13:21:53 -04:00
Smit Barmase
66fbfd7080 linux: Fix IME preedit text not showing in Terminal on Wayland (#37701)
Closes https://github.com/zed-industries/zed/issues/37268
 
Release Notes:

- Fixed an issue where IME preedit text was not showing in the Terminal
on Wayland.
2025-09-08 13:14:49 -04:00
Ben Kunkle
92912915fa onboarding: Fix font loading frame delay (#37668)
Closes #ISSUE

Fixed an issue where the first frame of the `Editing` page in onboarding
would have a slight delay before rendering the first time it was
navigated to. This was caused by listing the OS fonts on the main
thread, blocking rendering. This PR fixes the issue by adding a new
method to the font family cache to prefill the cache on a background
thread.

Release Notes:

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

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Anthony <anthony@zed.dev>
2025-09-08 11:58:41 -05:00
Ben Kunkle
018935de63 onboarding: Improve performance of AI upsell card (#37504)
Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
2025-09-08 11:55:43 -05:00
Max Brunsfeld
fbec5e8dd5 Disable foreign keys in sqlite when running migrations (#37572)
Closes #37473

Previously, we enabled foreign keys at all times for our sqlite database
that we use for client-side state.
The problem with this is that In sqlite, `alter table` is somewhat
limited, so for many migrations, you must *recreate* the table: create a
new table called e.g. `workspace__2`, then copy all of the data from
`workspaces` into `workspace__2`, then delete the old `workspaces` table
and rename `workspaces__2` to `workspaces`. The way foreign keys work in
sqlite, when we delete the old table, all of its associated records in
other tables will be deleted due to `on delete cascade` clauses.

Unfortunately, one of the types of associated records that can be
deleted are `editors`, which sometimes store unsaved text. It is very
bad to delete these records, as they are the *only* place that this
unsaved text is stored.

This has already happened multiple times as we have migrated tables as
we develop Zed, but I caused it to happened again in
https://github.com/zed-industries/zed/pull/36714.

The Sqlite docs recommend a multi-step approach to migrations where you:

* disable foreign keys
* start a transaction
* create a new table
* populate the new table with data from the old table
* delete the old table
* rename the new table to the old name
* run a foreign key check
* if it passes, commit the transaction
* enable foreign keys

In this PR, I've adjusted our sqlite migration code path to follow this
pattern more closely. Specifically, we disable foreign key checks before
running migrations, run a foreign key check before committing, and then
enable foreign key checks after the migrations are done.

In addition, I've added a generic query that we run *before* running the
foreign key check that explicitly deletes any rows that have dangling
foreign keys. This way, we avoid failing the migration (and breaking the
app) if a migration deletes data that *does* cause associated records to
need to be deleted.

But now, in the common case where we migrate old data in the new table
and keep the ids, all of the associated data will be preserved.

Release Notes:

- Fixed a bug where workspace state would be lost when upgrading from
Zed 0.201.x. or below.
2025-09-08 09:45:02 -07:00
Kirill Bulatov
bda30bb0eb Fixed LSP binary info not being shown in full (#37682)
Follow-up of https://github.com/zed-industries/zed/pull/37083
Closes https://github.com/zed-industries/zed/issues/37677

Release Notes:

- Fixed LSP binary info not being shown in full
2025-09-06 10:50:04 +03:00
Peter Tripp
aa95dbb670 linux: Restore ctrl-escape to keymap (#37636)
Closes: https://github.com/zed-industries/zed/issues/37628
Follow-up to: https://github.com/zed-industries/zed/pull/36712

Release Notes:

- linux: Fix for ctrl-escape not escaping the tab switcher.
2025-09-05 11:10:08 -04:00
Peter Tripp
ae4617a47e zed 0.203.2 2025-09-04 15:32:21 -04:00
Cole Miller
a0756db99b acp: Keep diff editors in sync with AgentFontSize global (#37559)
Release Notes:

- agent: Fixed `cmd-+` and `cmd--` not affecting the font size of diffs.
2025-09-04 15:42:50 -03:00
Anthony Eid
80f42ccd26 debugger: Fix stack frame filter crash (#37555)
The crash was caused by not accounting for the fact that a range of
collapse frames only counts as one entry. Causing the filter indices to
overshoot for indices after collapse frames (it was counting all
collapse frames instead of just one).

The test missed this because it all happened in one `cx.update` closure
and didn't render the stack frame list when the filter was applied. The
test has been updated to account for this.


Release Notes:

- N/A

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-04 15:42:29 -03:00
Ben Brandt
93066f1c52 acp: Don't share API key with Anthropic provider (#37543)
Since Claude Code has it's own preferred method of grabbing API keys, we
don't want to reuse this one.

Release Notes:

- acp: Don't share Anthropic API key from the Anthropic provider to
allow default Claude Code login options

---------

Co-authored-by: Agus Zubiaga <agus@zed.dev>
2025-09-04 15:42:12 -03:00
Max Brunsfeld
b482eba919 Revert "Remote: Change "sh -c" to "sh -lc" (#36760)" (#37417)
This reverts commit bf5ed6d1c9.

We believe this may be breaking some users whose shell initialization
scripts change the working directory.

Release Notes:

- N/A
2025-09-04 13:11:04 -04:00
Zed Bot
377c67be0d Bump to 0.203.1 for @agu-z 2025-09-03 23:28:27 +00:00
Agus Zubiaga
961f44ea82 acp: Receive available commands over notifications (#37499)
See: https://github.com/zed-industries/agent-client-protocol/pull/62

Release Notes:

- Agent Panel: Fixes an issue where Claude Code would timeout waiting
for slash commands to be loaded

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 20:20:03 -03:00
Cole Miller
6f8cfc4908 acp: Improve handling of invalid external agent server downloads (#37465)
Related to #37213, #37150

When listing previously-downloaded versions of an external agent, don't
try to use any downloads that are missing the agent entrypoint
(indicating that they're corrupt/unusable), and delete those versions,
so that we can attempt to download the latest version again.

Also report clearer errors when failing to start a session due to an
agent server entrypoint or root directory not existing.

Release Notes:

- N/A
2025-09-03 20:19:57 -03:00
Agus Zubiaga
7954420a12 acp: Display a new version call out when one is available (#37479)
<img width="500" alt="CleanShot 2025-09-03 at 16 13 59@2x"
src="https://github.com/user-attachments/assets/beb91365-28e2-4f87-a2c5-7136d37382c7"></img>



Release Notes:

- Agent Panel: Display a callout when a new version of an external agent
is available

---------

Co-authored-by: Cole Miller <cole@zed.dev>
2025-09-03 20:19:49 -03:00
Smit Barmase
9aa31527d8 editor: Do not correct text contrast on non-opaque editor (#37471)
We don’t know the background color behind a non-opaque editor, so we
should skip contrast correction in that case. This prevents
single-editor mode (which is always transparent) from showing weird text
colors when text is selected.

We can’t account for the actual background during contrast correction
because we compute contrast outside gpui, while the actual color
blending happens inside gpui during drawing.

<img width="522" height="145" alt="image"
src="https://github.com/user-attachments/assets/6ee71475-f666-482d-87e6-15cf4c4fceef"
/>

Release Notes:

- Fixed an issue where Command Palette text looked faded when selected.
2025-09-04 00:19:26 +05:30
Bennet Bo Fenner
11c1c5a4bb acp: Fix issue with claude code /logout command (#37452)
### First issue

In the scenario where you have an API key configured in Zed and you run
`/logout`, clicking on `Use Anthropic API Key` would show `Method not
implemented`.

This happened because we were only intercepting the `Use Anthropic API
Key` click if the provider was NOT authenticated, which would not be the
case when the user has an API key set.

### Second issue

When clicking on `Reset API Key` the modal would be dismissed even
though you picked no Authentication Method (which means you still would
be unauthenticated)

---

This PR fixes both of these issues

Release Notes:

- N/A
2025-09-03 14:09:30 +02:00
Bennet Bo Fenner
439f8bdd72 Add onboarding banner for claude code support (#37443)
Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2025-09-03 13:00:10 +02:00
Cole Miller
bbaf704599 acp: Fix handling of single-file worktrees (#37412)
When the first visible worktree is a single-file worktree, we would
previously try to use the absolute path of that file as the root
directory for external agents, causing an error. This PR changes how we
handle this situation: we'll use the root of the first non-single-file
visible worktree if there are any, and if there are none, the parent
directory of the first single-file visible worktree.

Related to #37213

Release Notes:

- acp: Fixed being unable to run external agents when a single file (not
part of a project) was opened in Zed.
2025-09-03 07:18:32 -03:00
Danilo Leal
a009bd6915 agent: Update message editor placeholder (#37441)
Release Notes:

- N/A
2025-09-03 11:55:42 +02:00
Peter Tripp
33a97504ca v0.203.x preview 2025-09-02 20:58:50 -04:00
48 changed files with 1230 additions and 479 deletions

7
Cargo.lock generated
View File

@@ -195,9 +195,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
version = "0.2.0-alpha.4"
version = "0.2.0-alpha.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "603941db1d130ee275840c465b73a2312727d4acef97449550ccf033de71301f"
checksum = "6d02292efd75080932b6466471d428c70e2ac06908ae24792fc7c36ecbaf67ca"
dependencies = [
"anyhow",
"async-broadcast",
@@ -15313,6 +15313,7 @@ dependencies = [
"futures 0.3.31",
"indoc",
"libsqlite3-sys",
"log",
"parking_lot",
"smol",
"sqlformat",
@@ -20398,7 +20399,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.203.0"
version = "0.203.4"
dependencies = [
"acp_tools",
"activity_indicator",

View File

@@ -428,7 +428,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
agent-client-protocol = { version = "0.2.0-alpha.4", features = ["unstable"]}
agent-client-protocol = { version = "0.2.0-alpha.6", features = ["unstable"]}
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -16,6 +16,7 @@
"up": "menu::SelectPrevious",
"enter": "menu::Confirm",
"ctrl-enter": "menu::SecondaryConfirm",
"ctrl-escape": "menu::Cancel",
"ctrl-c": "menu::Cancel",
"escape": "menu::Cancel",
"alt-shift-enter": "menu::Restart",

View File

@@ -785,7 +785,6 @@ pub struct AcpThread {
session_id: acp::SessionId,
token_usage: Option<TokenUsage>,
prompt_capabilities: acp::PromptCapabilities,
available_commands: Vec<acp::AvailableCommand>,
_observe_prompt_capabilities: Task<anyhow::Result<()>>,
determine_shell: Shared<Task<String>>,
terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
@@ -805,6 +804,7 @@ pub enum AcpThreadEvent {
LoadError(LoadError),
PromptCapabilitiesUpdated,
Refusal,
AvailableCommandsUpdated(Vec<acp::AvailableCommand>),
}
impl EventEmitter<AcpThreadEvent> for AcpThread {}
@@ -860,7 +860,6 @@ impl AcpThread {
action_log: Entity<ActionLog>,
session_id: acp::SessionId,
mut prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
available_commands: Vec<acp::AvailableCommand>,
cx: &mut Context<Self>,
) -> Self {
let prompt_capabilities = *prompt_capabilities_rx.borrow();
@@ -900,7 +899,6 @@ impl AcpThread {
session_id,
token_usage: None,
prompt_capabilities,
available_commands,
_observe_prompt_capabilities: task,
terminals: HashMap::default(),
determine_shell,
@@ -911,10 +909,6 @@ impl AcpThread {
self.prompt_capabilities
}
pub fn available_commands(&self) -> Vec<acp::AvailableCommand> {
self.available_commands.clone()
}
pub fn connection(&self) -> &Rc<dyn AgentConnection> {
&self.connection
}
@@ -1010,6 +1004,9 @@ impl AcpThread {
acp::SessionUpdate::Plan(plan) => {
self.update_plan(plan, cx);
}
acp::SessionUpdate::AvailableCommandsUpdate { available_commands } => {
cx.emit(AcpThreadEvent::AvailableCommandsUpdated(available_commands))
}
}
Ok(())
}
@@ -1643,13 +1640,13 @@ impl AcpThread {
cx.foreground_executor().spawn(send_task)
}
/// Rewinds this thread to before the entry at `index`, removing it and all
/// subsequent entries while reverting any changes made from that point.
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
return Task::ready(Err(anyhow!("not supported")));
};
let Some(message) = self.user_message(&id) else {
/// Restores the git working tree to the state at the given checkpoint (if one exists)
pub fn restore_checkpoint(
&mut self,
id: UserMessageId,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let Some((_, message)) = self.user_message_mut(&id) else {
return Task::ready(Err(anyhow!("message not found")));
};
@@ -1657,15 +1654,30 @@ impl AcpThread {
.checkpoint
.as_ref()
.map(|c| c.git_checkpoint.clone());
let rewind = self.rewind(id.clone(), cx);
let git_store = self.project.read(cx).git_store().clone();
cx.spawn(async move |this, cx| {
cx.spawn(async move |_, cx| {
rewind.await?;
if let Some(checkpoint) = checkpoint {
git_store
.update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))?
.await?;
}
Ok(())
})
}
/// Rewinds this thread to before the entry at `index`, removing it and all
/// subsequent entries while rejecting any action_log changes made from that point.
/// Unlike `restore_checkpoint`, this method does not restore from git.
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
let Some(truncate) = self.connection.truncate(&self.session_id, cx) else {
return Task::ready(Err(anyhow!("not supported")));
};
cx.spawn(async move |this, cx| {
cx.update(|cx| truncate.run(id.clone(), cx))?.await?;
this.update(cx, |this, cx| {
if let Some((ix, _)) = this.user_message_mut(&id) {
@@ -1673,7 +1685,11 @@ impl AcpThread {
this.entries.truncate(ix);
cx.emit(AcpThreadEvent::EntriesRemoved(range));
}
})
this.action_log()
.update(cx, |action_log, cx| action_log.reject_all_edits(cx))
})?
.await;
Ok(())
})
}
@@ -1730,20 +1746,6 @@ impl AcpThread {
})
}
fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> {
self.entries.iter().find_map(|entry| {
if let AgentThreadEntry::UserMessage(message) = entry {
if message.id.as_ref() == Some(id) {
Some(message)
} else {
None
}
} else {
None
}
})
}
fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> {
self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
if let AgentThreadEntry::UserMessage(message) = entry {
@@ -2687,7 +2689,7 @@ mod tests {
let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
panic!("unexpected entries {:?}", thread.entries)
};
thread.rewind(message.id.clone().unwrap(), cx)
thread.restore_checkpoint(message.id.clone().unwrap(), cx)
})
.await
.unwrap();
@@ -3080,7 +3082,6 @@ mod tests {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
});

View File

@@ -338,7 +338,6 @@ mod test_support {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
});

View File

@@ -292,7 +292,6 @@ impl NativeAgent {
action_log.clone(),
session_id.clone(),
prompt_capabilities_rx,
vec![],
cx,
)
});

View File

@@ -9,6 +9,7 @@ use futures::AsyncBufReadExt as _;
use futures::io::BufReader;
use project::Project;
use serde::Deserialize;
use util::ResultExt as _;
use std::{any::Any, cell::RefCell};
use std::{path::Path, rc::Rc};
@@ -29,6 +30,9 @@ pub struct AcpConnection {
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
auth_methods: Vec<acp::AuthMethod>,
agent_capabilities: acp::AgentCapabilities,
// NB: Don't move this into the wait_task, since we need to ensure the process is
// killed on drop (setting kill_on_drop on the command seems to not always work).
child: smol::process::Child,
_io_task: Task<Result<()>>,
_wait_task: Task<Result<()>>,
_stderr_task: Task<Result<()>>,
@@ -65,7 +69,6 @@ impl AcpConnection {
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stdout = child.stdout.take().context("Failed to take stdout")?;
@@ -102,8 +105,9 @@ impl AcpConnection {
let wait_task = cx.spawn({
let sessions = sessions.clone();
let status_fut = child.status();
async move |cx| {
let status = child.status().await?;
let status = status_fut.await?;
for session in sessions.borrow().values() {
session
@@ -152,6 +156,7 @@ impl AcpConnection {
_io_task: io_task,
_wait_task: wait_task,
_stderr_task: stderr_task,
child,
})
}
@@ -160,6 +165,13 @@ impl AcpConnection {
}
}
impl Drop for AcpConnection {
fn drop(&mut self) {
// See the comment on the child field.
self.child.kill().log_err();
}
}
impl AgentConnection for AcpConnection {
fn new_thread(
self: Rc<Self>,
@@ -224,7 +236,6 @@ impl AgentConnection for AcpConnection {
session_id.clone(),
// ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically.
watch::Receiver::constant(self.agent_capabilities.prompt_capabilities),
response.available_commands,
cx,
)
})?;

View File

@@ -45,11 +45,20 @@ pub fn init(cx: &mut App) {
pub struct AgentServerDelegate {
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
new_version_available: Option<watch::Sender<Option<String>>>,
}
impl AgentServerDelegate {
pub fn new(project: Entity<Project>, status_tx: Option<watch::Sender<SharedString>>) -> Self {
Self { project, status_tx }
pub fn new(
project: Entity<Project>,
status_tx: Option<watch::Sender<SharedString>>,
new_version_tx: Option<watch::Sender<Option<String>>>,
) -> Self {
Self {
project,
status_tx,
new_version_available: new_version_tx,
}
}
pub fn project(&self) -> &Entity<Project> {
@@ -73,6 +82,7 @@ impl AgentServerDelegate {
)));
};
let status_tx = self.status_tx;
let new_version_available = self.new_version_available;
cx.spawn(async move |cx| {
if !ignore_system_version {
@@ -101,9 +111,11 @@ impl AgentServerDelegate {
continue;
};
if let Some(version) = file_name
.to_str()
.and_then(|name| semver::Version::from_str(&name).ok())
if let Some(name) = file_name.to_str()
&& let Some(version) = semver::Version::from_str(name).ok()
&& fs
.is_file(&dir.join(file_name).join(&entrypoint_path))
.await
{
versions.push((version, file_name.to_owned()));
} else {
@@ -146,6 +158,7 @@ impl AgentServerDelegate {
cx.background_spawn({
let file_name = file_name.clone();
let dir = dir.clone();
let fs = fs.clone();
async move {
let latest_version =
node_runtime.npm_package_latest_version(&package_name).await;
@@ -160,6 +173,9 @@ impl AgentServerDelegate {
)
.await
.log_err();
if let Some(mut new_version_available) = new_version_available {
new_version_available.send(Some(latest_version)).ok();
}
}
}
})
@@ -171,7 +187,7 @@ impl AgentServerDelegate {
}
let dir = dir.clone();
cx.background_spawn(Self::download_latest_version(
fs,
fs.clone(),
dir.clone(),
node_runtime,
package_name,
@@ -179,14 +195,18 @@ impl AgentServerDelegate {
.await?
.into()
};
let agent_server_path = dir.join(version).join(entrypoint_path);
let agent_server_path_exists = fs.is_file(&agent_server_path).await;
anyhow::ensure!(
agent_server_path_exists,
"Missing entrypoint path {} after installation",
agent_server_path.to_string_lossy()
);
anyhow::Ok(AgentServerCommand {
path: node_path,
args: vec![
dir.join(version)
.join(entrypoint_path)
.to_string_lossy()
.to_string(),
],
args: vec![agent_server_path.to_string_lossy().to_string()],
env: Default::default(),
})
})

View File

@@ -1,4 +1,3 @@
use language_models::provider::anthropic::AnthropicLanguageModelProvider;
use settings::SettingsStore;
use std::path::Path;
use std::rc::Rc;
@@ -40,7 +39,7 @@ impl ClaudeCode {
Self::PACKAGE_NAME.into(),
"node_modules/@anthropic-ai/claude-code/cli.js".into(),
true,
None,
Some("0.2.5".parse().unwrap()),
cx,
)
})?
@@ -76,12 +75,20 @@ impl AgentServer for ClaudeCode {
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let root_dir = root_dir.to_path_buf();
let fs = delegate.project().read(cx).fs().clone();
let server_name = self.name();
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).claude.clone()
});
let project = delegate.project().clone();
cx.spawn(async move |cx| {
let mut project_env = project
.update(cx, |project, cx| {
project.directory_environment(root_dir.as_path().into(), cx)
})?
.await
.unwrap_or_default();
let mut command = if let Some(settings) = settings {
settings.command
} else {
@@ -97,17 +104,20 @@ impl AgentServer for ClaudeCode {
})?
.await?
};
project_env.extend(command.env.take().unwrap_or_default());
command.env = Some(project_env);
if let Some(api_key) = cx
.update(AnthropicLanguageModelProvider::api_key)?
.await
.ok()
{
command
.env
.get_or_insert_default()
.insert("ANTHROPIC_API_KEY".to_owned(), api_key.key);
}
command
.env
.get_or_insert_default()
.insert("ANTHROPIC_API_KEY".to_owned(), "".to_owned());
let root_dir_exists = fs.is_dir(&root_dir).await;
anyhow::ensure!(
root_dir_exists,
"Session root {} does not exist or is not a directory",
root_dir.to_string_lossy()
);
crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
})

View File

@@ -498,7 +498,7 @@ pub async fn new_test_thread(
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
let delegate = AgentServerDelegate::new(project.clone(), None);
let delegate = AgentServerDelegate::new(project.clone(), None, None);
let connection = cx
.update(|cx| server.connect(current_dir.as_ref(), delegate, cx))

View File

@@ -36,16 +36,24 @@ impl AgentServer for Gemini {
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let root_dir = root_dir.to_path_buf();
let fs = delegate.project().read(cx).fs().clone();
let server_name = self.name();
let settings = cx.read_global(|settings: &SettingsStore, _| {
settings.get::<AllAgentServersSettings>(None).gemini.clone()
});
let project = delegate.project().clone();
cx.spawn(async move |cx| {
let ignore_system_version = settings
.as_ref()
.and_then(|settings| settings.ignore_system_version)
.unwrap_or(true);
let mut project_env = project
.update(cx, |project, cx| {
project.directory_environment(root_dir.as_path().into(), cx)
})?
.await
.unwrap_or_default();
let mut command = if let Some(settings) = settings
&& let Some(command) = settings.custom_command()
{
@@ -66,13 +74,19 @@ impl AgentServer for Gemini {
if !command.args.contains(&ACP_ARG.into()) {
command.args.push(ACP_ARG.into());
}
if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
command
.env
.get_or_insert_default()
project_env
.insert("GEMINI_API_KEY".to_owned(), api_key.key);
}
project_env.extend(command.env.take().unwrap_or_default());
command.env = Some(project_env);
let root_dir_exists = fs.is_dir(&root_dir).await;
anyhow::ensure!(
root_dir_exists,
"Session root {} does not exist or is not a directory",
root_dir.to_string_lossy()
);
let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
match &result {
@@ -92,7 +106,7 @@ impl AgentServer for Gemini {
log::error!("connected to gemini, but missing prompt_capabilities.image (version is {current_version})");
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: command.path.to_string_lossy().to_string().into(),
command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
minimum_version: Self::MINIMUM_VERSION.into(),
}
.into());
@@ -129,7 +143,7 @@ impl AgentServer for Gemini {
if !supported {
return Err(LoadError::Unsupported {
current_version: current_version.into(),
command: command.path.to_string_lossy().to_string().into(),
command: (command.path.to_string_lossy().to_string() + " " + &command.args.join(" ")).into(),
minimum_version: Self::MINIMUM_VERSION.into(),
}
.into());

View File

@@ -207,7 +207,7 @@ impl EntryViewState {
self.entries.drain(range);
}
pub fn settings_changed(&mut self, cx: &mut App) {
pub fn agent_font_size_changed(&mut self, cx: &mut App) {
for entry in self.entries.iter() {
match entry {
Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {}

View File

@@ -700,7 +700,7 @@ impl MessageEditor {
self.project.read(cx).fs().clone(),
self.history_store.clone(),
));
let delegate = AgentServerDelegate::new(self.project.clone(), None);
let delegate = AgentServerDelegate::new(self.project.clone(), None, None);
let connection = server.connect(Path::new(""), delegate, cx);
cx.spawn(async move |_, cx| {
let agent = connection.await?;

View File

@@ -43,10 +43,10 @@ use std::{collections::BTreeMap, rc::Rc, time::Duration};
use task::SpawnInTerminal;
use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::ThemeSettings;
use theme::{AgentFontSize, ThemeSettings};
use ui::{
Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use workspace::{CollaboratorId, Workspace};
@@ -288,8 +288,9 @@ pub struct AcpThreadView {
prompt_capabilities: Rc<Cell<PromptCapabilities>>,
available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
is_loading_contents: bool,
new_server_version_available: Option<SharedString>,
_cancel_task: Option<Task<()>>,
_subscriptions: [Subscription; 3],
_subscriptions: [Subscription; 4],
}
enum ThreadState {
@@ -332,6 +333,11 @@ impl AcpThreadView {
let placeholder = if agent.name() == "Zed Agent" {
format!("Message the {} — @ to include context", agent.name())
} else if agent.name() == "Claude Code" || !available_commands.borrow().is_empty() {
format!(
"Message {} — @ to include context, / for commands",
agent.name()
)
} else {
format!("Message {} — @ to include context", agent.name())
};
@@ -374,7 +380,8 @@ impl AcpThreadView {
});
let subscriptions = [
cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
cx.observe_global_in::<SettingsStore>(window, Self::agent_font_size_changed),
cx.observe_global_in::<AgentFontSize>(window, Self::agent_font_size_changed),
cx.subscribe_in(&message_editor, window, Self::handle_message_editor_event),
cx.subscribe_in(&entry_view_state, window, Self::handle_entry_view_event),
];
@@ -411,9 +418,24 @@ impl AcpThreadView {
_subscriptions: subscriptions,
_cancel_task: None,
focus_handle: cx.focus_handle(),
new_server_version_available: None,
}
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.thread_state = Self::initial_state(
self.agent.clone(),
None,
self.workspace.clone(),
self.project.clone(),
window,
cx,
);
self.available_commands.replace(vec![]);
self.new_server_version_available.take();
cx.notify();
}
fn initial_state(
agent: Rc<dyn AgentServer>,
resume_thread: Option<DbThreadMetadata>,
@@ -427,14 +449,32 @@ impl AcpThreadView {
"External agents are not yet supported for remote projects.".into(),
));
}
let root_dir = project
.read(cx)
.visible_worktrees(cx)
let mut worktrees = project.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
// Pick the first non-single-file worktree for the root directory if there are any,
// and otherwise the parent of a single-file worktree, falling back to $HOME if there are no visible worktrees.
worktrees.sort_by(|l, r| {
l.read(cx)
.is_single_file()
.cmp(&r.read(cx).is_single_file())
});
let root_dir = worktrees
.into_iter()
.filter_map(|worktree| {
if worktree.read(cx).is_single_file() {
Some(worktree.read(cx).abs_path().parent()?.into())
} else {
Some(worktree.read(cx).abs_path())
}
})
.next()
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
let (tx, mut rx) = watch::channel("Loading…".into());
let delegate = AgentServerDelegate::new(project.clone(), Some(tx));
let (status_tx, mut status_rx) = watch::channel("Loading…".into());
let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
let delegate = AgentServerDelegate::new(
project.clone(),
Some(status_tx),
Some(new_version_available_tx),
);
let connect_task = agent.connect(&root_dir, delegate, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
@@ -497,26 +537,6 @@ impl AcpThreadView {
Ok(thread) => {
let action_log = thread.read(cx).action_log().clone();
let mut available_commands = thread.read(cx).available_commands();
if connection
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
available_commands.push(acp::AvailableCommand {
name: "login".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
available_commands.push(acp::AvailableCommand {
name: "logout".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
}
this.available_commands.replace(available_commands);
this.prompt_capabilities
.set(thread.read(cx).prompt_capabilities());
@@ -609,10 +629,23 @@ impl AcpThreadView {
.log_err();
});
cx.spawn(async move |this, cx| {
while let Ok(new_version) = new_version_available_rx.recv().await {
if let Some(new_version) = new_version {
this.update(cx, |this, cx| {
this.new_server_version_available = Some(new_version.into());
cx.notify();
})
.log_err();
}
}
})
.detach();
let loading_view = cx.new(|cx| {
let update_title_task = cx.spawn(async move |this, cx| {
loop {
let status = rx.recv().await?;
let status = status_rx.recv().await?;
this.update(cx, |this: &mut LoadingView, cx| {
this.title = status;
cx.notify();
@@ -648,17 +681,13 @@ impl AcpThreadView {
move |_, ev, window, cx| {
if let language_model::Event::ProviderStateChanged(updated_provider_id) = &ev
&& &provider_id == updated_provider_id
&& LanguageModelRegistry::global(cx)
.read(cx)
.provider(&provider_id)
.map_or(false, |provider| provider.is_authenticated(cx))
{
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
agent.clone(),
None,
this.workspace.clone(),
this.project.clone(),
window,
cx,
);
cx.notify();
this.reset(window, cx);
})
.ok();
}
@@ -884,7 +913,7 @@ impl AcpThreadView {
}
}
ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => {
self.regenerate(event.entry_index, editor, window, cx);
self.regenerate(event.entry_index, editor.clone(), window, cx);
}
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
self.cancel_editing(&Default::default(), window, cx);
@@ -955,7 +984,7 @@ impl AcpThreadView {
this,
AuthRequired {
description: None,
provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
provider_id: None,
},
agent,
connection,
@@ -1108,7 +1137,7 @@ impl AcpThreadView {
fn regenerate(
&mut self,
entry_ix: usize,
message_editor: &Entity<MessageEditor>,
message_editor: Entity<MessageEditor>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1125,16 +1154,18 @@ impl AcpThreadView {
return;
};
let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
let task = cx.spawn(async move |_, cx| {
let contents = contents.await?;
cx.spawn_in(window, async move |this, cx| {
thread
.update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
.await?;
Ok(contents)
});
self.send_impl(task, window, cx);
let contents =
message_editor.update(cx, |message_editor, cx| message_editor.contents(cx))?;
this.update_in(cx, |this, window, cx| {
this.send_impl(contents, window, cx);
})?;
anyhow::Ok(())
})
.detach();
}
fn open_agent_diff(&mut self, _: &OpenAgentDiff, window: &mut Window, cx: &mut Context<Self>) {
@@ -1296,6 +1327,30 @@ impl AcpThreadView {
.set(thread.read(cx).prompt_capabilities());
}
AcpThreadEvent::TokenUsageUpdated => {}
AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
let mut available_commands = available_commands.clone();
if thread
.read(cx)
.connection()
.auth_methods()
.iter()
.any(|method| method.id.0.as_ref() == "claude-login")
{
available_commands.push(acp::AvailableCommand {
name: "login".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
available_commands.push(acp::AvailableCommand {
name: "logout".to_owned(),
description: "Authenticate".to_owned(),
input: None,
});
}
self.available_commands.replace(available_commands);
}
}
cx.notify();
}
@@ -1347,11 +1402,11 @@ impl AcpThreadView {
.read(cx)
.provider(&language_model::ANTHROPIC_PROVIDER_ID)
.unwrap();
if !provider.is_authenticated(cx) {
let this = cx.weak_entity();
let agent = self.agent.clone();
let connection = connection.clone();
window.defer(cx, |window, cx| {
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 {
@@ -1363,9 +1418,21 @@ impl AcpThreadView {
window,
cx,
);
});
return;
}
} else {
this.update(cx, |this, cx| {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
this.project.clone(),
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()
@@ -1409,7 +1476,6 @@ impl AcpThreadView {
cx.notify();
self.auth_task =
Some(cx.spawn_in(window, {
let project = self.project.clone();
let agent = self.agent.clone();
async move |this, cx| {
let result = authenticate.await;
@@ -1438,14 +1504,7 @@ impl AcpThreadView {
}
this.handle_thread_error(err, cx);
} else {
this.thread_state = Self::initial_state(
agent,
None,
this.workspace.clone(),
project.clone(),
window,
cx,
)
this.reset(window, cx);
}
this.auth_task.take()
})
@@ -1467,7 +1526,7 @@ impl AcpThreadView {
let cwd = project.first_project_directory(cx);
let shell = project.terminal_settings(&cwd, cx).shell.clone();
let delegate = AgentServerDelegate::new(project_entity.clone(), None);
let delegate = AgentServerDelegate::new(project_entity.clone(), None, None);
let command = ClaudeCode::login_command(delegate, cx);
window.spawn(cx, async move |cx| {
@@ -1569,14 +1628,16 @@ impl AcpThreadView {
cx.notify();
}
fn rewind(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
fn restore_checkpoint(&mut self, message_id: &UserMessageId, cx: &mut Context<Self>) {
let Some(thread) = self.thread() else {
return;
};
thread
.update(cx, |thread, cx| thread.rewind(message_id.clone(), cx))
.update(cx, |thread, cx| {
thread.restore_checkpoint(message_id.clone(), cx)
})
.detach_and_log_err(cx);
cx.notify();
}
fn render_entry(
@@ -1646,8 +1707,9 @@ impl AcpThreadView {
.label_size(LabelSize::XSmall)
.icon_color(Color::Muted)
.color(Color::Muted)
.tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
.on_click(cx.listener(move |this, _, _window, cx| {
this.rewind(&message_id, cx);
this.restore_checkpoint(&message_id, cx);
}))
)
.child(Divider::horizontal())
@@ -1718,7 +1780,7 @@ impl AcpThreadView {
let editor = editor.clone();
move |this, _, window, cx| {
this.regenerate(
entry_ix, &editor, window, cx,
entry_ix, editor.clone(), window, cx,
);
}
})).into_any_element()
@@ -2954,6 +3016,8 @@ impl AcpThreadView {
let show_description =
configuration_view.is_none() && description.is_none() && pending_auth_method.is_none();
let auth_methods = connection.auth_methods();
v_flex().flex_1().size_full().justify_end().child(
v_flex()
.p_2()
@@ -2984,21 +3048,23 @@ impl AcpThreadView {
.cloned()
.map(|view| div().w_full().child(view)),
)
.when(
show_description,
|el| {
el.child(
Label::new(format!(
"You are not currently authenticated with {}. Please choose one of the following options:",
self.agent.name()
))
.size(LabelSize::Small)
.color(Color::Muted)
.mb_1()
.ml_5(),
)
},
)
.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()
@@ -3010,12 +3076,12 @@ impl AcpThreadView {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2)
.with_rotate_animation(2),
)
.child(Label::new("Authenticating…").size(LabelSize::Small)),
)
})
.when(!connection.auth_methods().is_empty(), |this| {
.when(!auth_methods.is_empty(), |this| {
this.child(
h_flex()
.justify_end()
@@ -3027,38 +3093,32 @@ impl AcpThreadView {
.pt_2()
.border_color(cx.theme().colors().border.opacity(0.8))
})
.children(
connection
.auth_methods()
.iter()
.enumerate()
.rev()
.map(|(ix, method)| {
Button::new(
SharedString::from(method.id.0.clone()),
method.name.clone(),
)
.when(ix == 0, |el| {
el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
})
.label_size(LabelSize::Small)
.on_click({
let method_id = method.id.clone();
cx.listener(move |this, _, window, cx| {
telemetry::event!(
"Authenticate Agent Started",
agent = this.agent.telemetry_id(),
method = method_id
);
.children(connection.auth_methods().iter().enumerate().rev().map(
|(ix, method)| {
Button::new(
SharedString::from(method.id.0.clone()),
method.name.clone(),
)
.when(ix == 0, |el| {
el.style(ButtonStyle::Tinted(ui::TintColor::Warning))
})
.label_size(LabelSize::Small)
.on_click({
let method_id = method.id.clone();
cx.listener(move |this, _, window, cx| {
telemetry::event!(
"Authenticate Agent Started",
agent = this.agent.telemetry_id(),
method = method_id
);
this.authenticate(method_id.clone(), window, cx)
})
this.authenticate(method_id.clone(), window, cx)
})
}),
),
})
},
)),
)
})
}),
)
}
@@ -4681,9 +4741,9 @@ impl AcpThreadView {
)
}
fn settings_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
fn agent_font_size_changed(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.entry_view_state.update(cx, |entry_view_state, cx| {
entry_view_state.settings_changed(cx);
entry_view_state.agent_font_size_changed(cx);
});
}
@@ -4766,6 +4826,38 @@ impl AcpThreadView {
Some(div().child(content))
}
fn render_new_version_callout(&self, version: &SharedString, cx: &mut Context<Self>) -> Div {
v_flex().w_full().justify_end().child(
h_flex()
.p_2()
.pr_3()
.w_full()
.gap_1p5()
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().element_background)
.child(
h_flex()
.flex_1()
.gap_1p5()
.child(
Icon::new(IconName::Download)
.color(Color::Accent)
.size(IconSize::Small),
)
.child(Label::new("New version available").size(LabelSize::Small)),
)
.child(
Button::new("update-button", format!("Update to v{}", version))
.label_size(LabelSize::Small)
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(cx.listener(|this, _, window, cx| {
this.reset(window, cx);
})),
),
)
}
fn get_current_model_name(&self, cx: &App) -> SharedString {
// For native agent (Zed Agent), use the specific model name (e.g., "Claude 3.5 Sonnet")
// For ACP agents, use the agent name (e.g., "Claude Code", "Gemini CLI")
@@ -5176,6 +5268,12 @@ impl Render for AcpThreadView {
})
.children(self.render_thread_retry_status_callout(window, cx))
.children(self.render_thread_error(window, cx))
.when_some(
self.new_server_version_available.as_ref().filter(|_| {
!has_messages || !matches!(self.thread_state, ThreadState::Ready { .. })
}),
|this, version| this.child(self.render_new_version_callout(&version, cx)),
)
.children(
if let Some(usage_callout) = self.render_usage_callout(line_height, cx) {
Some(usage_callout.into_any_element())
@@ -5656,7 +5754,6 @@ pub(crate) mod tests {
audio: true,
embedded_context: true,
}),
vec![],
cx,
)
})))
@@ -5716,7 +5813,6 @@ pub(crate) mod tests {
audio: true,
embedded_context: true,
}),
Vec::new(),
cx,
)
})))

View File

@@ -1528,6 +1528,7 @@ impl AgentDiff {
| AcpThreadEvent::EntriesRemoved(_)
| AcpThreadEvent::ToolAuthorizationRequired
| AcpThreadEvent::PromptCapabilitiesUpdated
| AcpThreadEvent::AvailableCommandsUpdated(_)
| AcpThreadEvent::Retry(_) => {}
}
}

View File

@@ -10,11 +10,11 @@ use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
use zed_actions::OpenBrowser;
use zed_actions::agent::ReauthenticateAgent;
use zed_actions::agent::{OpenClaudeCodeOnboardingModal, ReauthenticateAgent};
use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
use crate::agent_diff::AgentDiffThread;
use crate::ui::AcpOnboardingModal;
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
@@ -207,6 +207,9 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
AcpOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|workspace, _: &OpenClaudeCodeOnboardingModal, window, cx| {
ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
})
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();

View File

@@ -1,6 +1,7 @@
mod acp_onboarding_modal;
mod agent_notification;
mod burn_mode_tooltip;
mod claude_code_onboarding_modal;
mod context_pill;
mod end_trial_upsell;
mod onboarding_modal;
@@ -10,6 +11,7 @@ mod unavailable_editing_tooltip;
pub use acp_onboarding_modal::*;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use claude_code_onboarding_modal::*;
pub use context_pill::*;
pub use end_trial_upsell::*;
pub use onboarding_modal::*;

View File

@@ -141,20 +141,12 @@ impl Render for AcpOnboardingModal {
.bg(gpui::black().opacity(0.15)),
)
.child(
h_flex()
.gap_4()
.child(
Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(111.),
rems_from_px(41.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
),
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
v_flex()

View File

@@ -0,0 +1,254 @@
use client::zed_urls;
use gpui::{
ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
linear_color_stop, linear_gradient,
};
use ui::{TintColor, Vector, VectorName, prelude::*};
use workspace::{ModalView, Workspace};
use crate::agent_panel::{AgentPanel, AgentType};
macro_rules! claude_code_onboarding_event {
($name:expr) => {
telemetry::event!($name, source = "ACP Claude Code Onboarding");
};
($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+);
};
}
pub struct ClaudeCodeOnboardingModal {
focus_handle: FocusHandle,
workspace: Entity<Workspace>,
}
impl ClaudeCodeOnboardingModal {
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
let workspace_entity = cx.entity();
workspace.toggle_modal(window, cx, |_window, cx| Self {
workspace: workspace_entity,
focus_handle: cx.focus_handle(),
});
}
fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.new_agent_thread(AgentType::ClaudeCode, window, cx);
});
}
});
cx.emit(DismissEvent);
claude_code_onboarding_event!("Open Panel Clicked");
}
fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
cx.open_url(&zed_urls::external_agents_docs(cx));
cx.notify();
claude_code_onboarding_event!("Documentation Link Clicked");
}
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ClaudeCodeOnboardingModal {}
impl Focusable for ClaudeCodeOnboardingModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for ClaudeCodeOnboardingModal {}
impl Render for ClaudeCodeOnboardingModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let illustration_element = |icon: IconName, label: Option<SharedString>, opacity: f32| {
h_flex()
.px_1()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.05))
.border_1()
.border_color(cx.theme().colors().border)
.border_dashed()
.child(
Icon::new(icon)
.size(IconSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
)
.map(|this| {
if let Some(label_text) = label {
this.child(
Label::new(label_text)
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(
div().w_16().h_1().rounded_full().bg(cx
.theme()
.colors()
.element_active
.opacity(0.6)),
)
}
})
.opacity(opacity)
};
let illustration = h_flex()
.relative()
.h(rems_from_px(126.))
.bg(cx.theme().colors().editor_background)
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.justify_center()
.gap_8()
.rounded_t_md()
.overflow_hidden()
.child(
div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
),
)
.child(div().absolute().inset_0().size_full().bg(linear_gradient(
0.,
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.1),
0.9,
),
linear_color_stop(
cx.theme().colors().elevated_surface_background.opacity(0.),
0.,
),
)))
.child(
div()
.absolute()
.inset_0()
.size_full()
.bg(gpui::black().opacity(0.15)),
)
.child(
Vector::new(
VectorName::AcpLogoSerif,
rems_from_px(257.),
rems_from_px(47.),
)
.color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
)
.child(
v_flex()
.gap_1p5()
.child(illustration_element(IconName::Stop, None, 0.15))
.child(illustration_element(
IconName::AiGemini,
Some("New Gemini CLI Thread".into()),
0.3,
))
.child(
h_flex()
.pl_1()
.pr_2()
.py_0p5()
.gap_1()
.rounded_sm()
.bg(cx.theme().colors().element_active.opacity(0.2))
.border_1()
.border_color(cx.theme().colors().border)
.child(
Icon::new(IconName::AiClaude)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new("New Claude Code Thread").size(LabelSize::Small)),
)
.child(illustration_element(
IconName::Stop,
Some("Your Agent Here".into()),
0.3,
))
.child(illustration_element(IconName::Stop, None, 0.15)),
);
let heading = v_flex()
.w_full()
.gap_1()
.child(
Label::new("Beta Release")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(Headline::new("Claude Code: Natively in Zed").size(HeadlineSize::Large));
let copy = "Powered by the Agent Client Protocol, you can now run Claude Code as\na first-class citizen in Zed's agent panel.";
let open_panel_button = Button::new("open-panel", "Start with Claude Code")
.icon_size(IconSize::Indicator)
.style(ButtonStyle::Tinted(TintColor::Accent))
.full_width()
.on_click(cx.listener(Self::open_panel));
let docs_button = Button::new("add-other-agents", "Add Other Agents")
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::Indicator)
.icon_color(Color::Muted)
.full_width()
.on_click(cx.listener(Self::view_docs));
let close_button = h_flex().absolute().top_2().right_2().child(
IconButton::new("cancel", IconName::Close).on_click(cx.listener(
|_, _: &ClickEvent, _window, cx| {
claude_code_onboarding_event!("Canceled", trigger = "X click");
cx.emit(DismissEvent);
},
)),
);
v_flex()
.id("acp-onboarding")
.key_context("AcpOnboardingModal")
.relative()
.w(rems(34.))
.h_full()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.overflow_hidden()
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
claude_code_onboarding_event!("Canceled", trigger = "Action");
cx.emit(DismissEvent);
}))
.on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
this.focus_handle.focus(window);
}))
.child(illustration)
.child(
v_flex()
.p_4()
.gap_2()
.child(heading)
.child(Label::new(copy).color(Color::Muted))
.child(
v_flex()
.w_full()
.mt_2()
.gap_1()
.child(open_panel_button)
.child(docs_button),
),
)
.child(close_button)
}
}

View File

@@ -86,10 +86,16 @@ impl RenderOnce for AiUpsellCard {
)
.child(plan_definitions.free_plan());
let grid_bg = h_flex().absolute().inset_0().w_full().h(px(240.)).child(
Vector::new(VectorName::Grid, rems_from_px(500.), rems_from_px(240.))
.color(Color::Custom(cx.theme().colors().border.opacity(0.05))),
);
let grid_bg = h_flex()
.absolute()
.inset_0()
.w_full()
.h(px(240.))
.bg(gpui::pattern_slash(
cx.theme().colors().border.opacity(0.1),
2.,
25.,
));
let gradient_bg = div()
.absolute()

View File

@@ -28,8 +28,8 @@ pub enum StackFrameListEvent {
}
/// Represents the filter applied to the stack frame list
#[derive(PartialEq, Eq, Copy, Clone)]
enum StackFrameFilter {
#[derive(PartialEq, Eq, Copy, Clone, Debug)]
pub(crate) enum StackFrameFilter {
/// Show all frames
All,
/// Show only frames from the user's code
@@ -174,19 +174,29 @@ impl StackFrameList {
#[cfg(test)]
pub(crate) fn dap_stack_frames(&self, cx: &mut App) -> Vec<dap::StackFrame> {
self.stack_frames(cx)
.unwrap_or_default()
.into_iter()
.enumerate()
.filter(|(ix, _)| {
self.list_filter == StackFrameFilter::All
|| self
.filter_entries_indices
.binary_search_by_key(&ix, |ix| ix)
.is_ok()
})
.map(|(_, stack_frame)| stack_frame.dap)
.collect()
match self.list_filter {
StackFrameFilter::All => self
.stack_frames(cx)
.unwrap_or_default()
.into_iter()
.map(|stack_frame| stack_frame.dap)
.collect(),
StackFrameFilter::OnlyUserFrames => self
.filter_entries_indices
.iter()
.map(|ix| match &self.entries[*ix] {
StackFrameEntry::Label(label) => label,
StackFrameEntry::Collapsed(_) => panic!("Collapsed tabs should not be visible"),
StackFrameEntry::Normal(frame) => frame,
})
.cloned()
.collect(),
}
}
#[cfg(test)]
pub(crate) fn list_filter(&self) -> StackFrameFilter {
self.list_filter
}
pub fn opened_stack_frame_id(&self) -> Option<StackFrameId> {
@@ -246,6 +256,7 @@ impl StackFrameList {
self.entries.clear();
self.selected_ix = None;
self.list_state.reset(0);
self.filter_entries_indices.clear();
cx.emit(StackFrameListEvent::BuiltEntries);
cx.notify();
return;
@@ -263,7 +274,7 @@ impl StackFrameList {
.unwrap_or_default();
let mut filter_entries_indices = Vec::default();
for (ix, stack_frame) in stack_frames.iter().enumerate() {
for stack_frame in stack_frames.iter() {
let frame_in_visible_worktree = stack_frame.dap.source.as_ref().is_some_and(|source| {
source.path.as_ref().is_some_and(|path| {
worktree_prefixes
@@ -273,10 +284,6 @@ impl StackFrameList {
})
});
if frame_in_visible_worktree {
filter_entries_indices.push(ix);
}
match stack_frame.dap.presentation_hint {
Some(dap::StackFramePresentationHint::Deemphasize)
| Some(dap::StackFramePresentationHint::Subtle) => {
@@ -302,6 +309,9 @@ impl StackFrameList {
first_stack_frame_with_path.get_or_insert(entries.len());
}
entries.push(StackFrameEntry::Normal(stack_frame.dap.clone()));
if frame_in_visible_worktree {
filter_entries_indices.push(entries.len() - 1);
}
}
}
}
@@ -309,7 +319,6 @@ impl StackFrameList {
let collapsed_entries = std::mem::take(&mut collapsed_entries);
if !collapsed_entries.is_empty() {
entries.push(StackFrameEntry::Collapsed(collapsed_entries));
self.filter_entries_indices.push(entries.len() - 1);
}
self.entries = entries;
self.filter_entries_indices = filter_entries_indices;
@@ -612,7 +621,16 @@ impl StackFrameList {
let entries = std::mem::take(stack_frames)
.into_iter()
.map(StackFrameEntry::Normal);
// HERE
let entries_len = entries.len();
self.entries.splice(ix..ix + 1, entries);
let (Ok(filtered_indices_start) | Err(filtered_indices_start)) =
self.filter_entries_indices.binary_search(&ix);
for idx in &mut self.filter_entries_indices[filtered_indices_start..] {
*idx += entries_len - 1;
}
self.selected_ix = Some(ix);
self.list_state.reset(self.entries.len());
cx.emit(StackFrameListEvent::BuiltEntries);

View File

@@ -1,6 +1,6 @@
use crate::{
debugger_panel::DebugPanel,
session::running::stack_frame_list::StackFrameEntry,
session::running::stack_frame_list::{StackFrameEntry, StackFrameFilter},
tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session},
};
use dap::{
@@ -867,6 +867,28 @@ async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppC
},
StackFrame {
id: 4,
name: "node:internal/modules/run_main2".into(),
source: Some(dap::Source {
name: Some("run_main.js".into()),
path: Some(path!("/usr/lib/node/internal/modules/run_main2.js").into()),
source_reference: None,
presentation_hint: None,
origin: None,
sources: None,
adapter_data: None,
checksums: None,
}),
line: 50,
column: 1,
end_line: None,
end_column: None,
can_restart: None,
instruction_pointer_reference: None,
module_id: None,
presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize),
},
StackFrame {
id: 5,
name: "doSomething".into(),
source: Some(dap::Source {
name: Some("test.js".into()),
@@ -957,83 +979,119 @@ async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppC
cx.run_until_parked();
active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| {
let stack_frame_list = debug_panel_item
.running_state()
.update(cx, |state, _| state.stack_frame_list().clone());
let stack_frame_list =
active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| {
let stack_frame_list = debug_panel_item
.running_state()
.update(cx, |state, _| state.stack_frame_list().clone());
stack_frame_list.update(cx, |stack_frame_list, cx| {
stack_frame_list.build_entries(true, window, cx);
stack_frame_list.update(cx, |stack_frame_list, cx| {
stack_frame_list.build_entries(true, window, cx);
// Verify we have the expected collapsed structure
assert_eq!(
stack_frame_list.entries(),
&vec![
StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
StackFrameEntry::Collapsed(vec![
stack_frames_for_assertions[1].clone(),
stack_frames_for_assertions[2].clone()
]),
StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
]
);
// Verify we have the expected collapsed structure
assert_eq!(
stack_frame_list.entries(),
&vec![
StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
StackFrameEntry::Collapsed(vec![
stack_frames_for_assertions[1].clone(),
stack_frames_for_assertions[2].clone(),
stack_frames_for_assertions[3].clone()
]),
StackFrameEntry::Normal(stack_frames_for_assertions[4].clone()),
]
);
});
// Test 1: Verify filtering works
let all_frames = stack_frame_list.flatten_entries(true, false);
assert_eq!(all_frames.len(), 4, "Should see all 4 frames initially");
// Toggle to user frames only
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
let user_frames = stack_frame_list.dap_stack_frames(cx);
assert_eq!(user_frames.len(), 2, "Should only see 2 user frames");
assert_eq!(user_frames[0].name, "main");
assert_eq!(user_frames[1].name, "doSomething");
// Test 2: Verify filtering toggles correctly
// Check we can toggle back and see all frames again
// Toggle back to all frames
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
let all_frames_again = stack_frame_list.flatten_entries(true, false);
assert_eq!(
all_frames_again.len(),
4,
"Should see all 4 frames after toggling back"
);
// Test 3: Verify collapsed entries stay expanded
stack_frame_list.expand_collapsed_entry(1, cx);
assert_eq!(
stack_frame_list.entries(),
&vec![
StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
]
);
// Toggle filter twice
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
// Verify entries remain expanded
assert_eq!(
stack_frame_list.entries(),
&vec![
StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
],
"Expanded entries should remain expanded after toggling filter"
);
});
stack_frame_list.update(cx, |stack_frame_list, cx| {
let all_frames = stack_frame_list.flatten_entries(true, false);
assert_eq!(all_frames.len(), 5, "Should see all 5 frames initially");
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
assert_eq!(
stack_frame_list.list_filter(),
StackFrameFilter::OnlyUserFrames
);
});
stack_frame_list.update(cx, |stack_frame_list, cx| {
let user_frames = stack_frame_list.dap_stack_frames(cx);
assert_eq!(user_frames.len(), 2, "Should only see 2 user frames");
assert_eq!(user_frames[0].name, "main");
assert_eq!(user_frames[1].name, "doSomething");
// Toggle back to all frames
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
assert_eq!(stack_frame_list.list_filter(), StackFrameFilter::All);
});
stack_frame_list.update(cx, |stack_frame_list, cx| {
let all_frames_again = stack_frame_list.flatten_entries(true, false);
assert_eq!(
all_frames_again.len(),
5,
"Should see all 5 frames after toggling back"
);
// Test 3: Verify collapsed entries stay expanded
stack_frame_list.expand_collapsed_entry(1, cx);
assert_eq!(
stack_frame_list.entries(),
&vec![
StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[4].clone()),
]
);
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
assert_eq!(
stack_frame_list.list_filter(),
StackFrameFilter::OnlyUserFrames
);
});
stack_frame_list.update(cx, |stack_frame_list, cx| {
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
assert_eq!(stack_frame_list.list_filter(), StackFrameFilter::All);
});
stack_frame_list.update(cx, |stack_frame_list, cx| {
stack_frame_list
.toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
assert_eq!(
stack_frame_list.list_filter(),
StackFrameFilter::OnlyUserFrames
);
assert_eq!(
stack_frame_list.dap_stack_frames(cx).as_slice(),
&[
stack_frames_for_assertions[0].clone(),
stack_frames_for_assertions[4].clone()
]
);
// Verify entries remain expanded
assert_eq!(
stack_frame_list.entries(),
&vec![
StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
StackFrameEntry::Normal(stack_frames_for_assertions[4].clone()),
],
"Expanded entries should remain expanded after toggling filter"
);
});
}

View File

@@ -3270,6 +3270,10 @@ impl EditorElement {
if rows.start >= rows.end {
return Vec::new();
}
if !base_background.is_opaque() {
// We don't actually know what color is behind this editor.
return Vec::new();
}
let highlight_iter = highlight_ranges.iter().cloned();
let selection_iter = selections.iter().flat_map(|(player_color, layouts)| {
let color = player_color.selection;
@@ -10974,7 +10978,7 @@ mod tests {
#[gpui::test]
fn test_merge_overlapping_ranges() {
let base_bg = Hsla::default();
let base_bg = Hsla::white();
let color1 = Hsla {
h: 0.0,
s: 0.5,
@@ -11044,7 +11048,7 @@ mod tests {
#[gpui::test]
fn test_bg_segments_per_row() {
let base_bg = Hsla::default();
let base_bg = Hsla::white();
// Case A: selection spans three display rows: row 1 [5, end), full row 2, row 3 [0, 7)
{

View File

@@ -40,8 +40,7 @@ use gpui::{
use itertools::Itertools;
use language::{Buffer, File};
use language_model::{
ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, Role,
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
};
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use multi_buffer::ExcerptInfo;
@@ -1860,13 +1859,17 @@ impl GitPanel {
/// Generates a commit message using an LLM.
pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai {
if !self.can_commit()
|| DisableAiSettings::get_global(cx).disable_ai
|| !agent_settings::AgentSettings::get_global(cx).enabled
{
return;
}
let model = match current_language_model(cx) {
Some(value) => value,
None => return,
let Some(ConfiguredModel { provider, model }) =
LanguageModelRegistry::read_global(cx).commit_message_model()
else {
return;
};
let Some(repo) = self.active_repository.as_ref() else {
@@ -1891,6 +1894,16 @@ impl GitPanel {
this.generate_commit_message_task.take();
});
if let Some(task) = cx.update(|cx| {
if !provider.is_authenticated(cx) {
Some(provider.authenticate(cx))
} else {
None
}
})? {
task.await.log_err();
};
let mut diff_text = match diff.await {
Ok(result) => match result {
Ok(text) => text,
@@ -3080,9 +3093,18 @@ impl GitPanel {
&self,
cx: &Context<Self>,
) -> Option<AnyElement> {
current_language_model(cx).is_some().then(|| {
if self.generate_commit_message_task.is_some() {
return h_flex()
if !agent_settings::AgentSettings::get_global(cx).enabled
|| DisableAiSettings::get_global(cx).disable_ai
|| LanguageModelRegistry::read_global(cx)
.commit_message_model()
.is_none()
{
return None;
}
if self.generate_commit_message_task.is_some() {
return Some(
h_flex()
.gap_1()
.child(
Icon::new(IconName::ArrowCircle)
@@ -3095,11 +3117,13 @@ impl GitPanel {
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element();
}
.into_any_element(),
);
}
let can_commit = self.can_commit();
let editor_focus_handle = self.commit_editor.focus_handle(cx);
let can_commit = self.can_commit();
let editor_focus_handle = self.commit_editor.focus_handle(cx);
Some(
IconButton::new("generate-commit-message", IconName::AiEdit)
.shape(ui::IconButtonShape::Square)
.icon_color(Color::Muted)
@@ -3120,8 +3144,8 @@ impl GitPanel {
.on_click(cx.listener(move |this, _event, _window, cx| {
this.generate_commit_message(cx);
}))
.into_any_element()
})
.into_any_element(),
)
}
pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
@@ -4453,20 +4477,6 @@ impl GitPanel {
}
}
fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
let is_enabled = agent_settings::AgentSettings::get_global(cx).enabled
&& !DisableAiSettings::get_global(cx).disable_ai;
is_enabled
.then(|| {
let ConfiguredModel { provider, model } =
LanguageModelRegistry::read_global(cx).commit_message_model()?;
provider.is_authenticated(cx).then(|| model)
})
.flatten()
}
impl Render for GitPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let project = self.project.read(cx);

View File

@@ -473,6 +473,11 @@ impl Hsla {
self.a == 0.0
}
/// Returns true if the HSLA color is fully opaque, false otherwise.
pub fn is_opaque(&self) -> bool {
self.a == 1.0
}
/// Blends `other` on top of `self` based on `other`'s alpha value. The resulting color is a combination of `self`'s and `other`'s colors.
///
/// If `other`'s alpha value is 1.0 or greater, `other` color is fully opaque, thus `other` is returned as the output color.

View File

@@ -325,7 +325,7 @@ impl LspLogView {
let server_info = format!(
"* Server: {NAME} (id {ID})
* Binary: {BINARY:#?}
* Binary: {BINARY}
* Registered workspace folders:
{WORKSPACE_FOLDERS}
@@ -335,10 +335,10 @@ impl LspLogView {
* Configuration: {CONFIGURATION}",
NAME = info.name,
ID = info.id,
BINARY = info.binary.as_ref().map_or_else(
|| "Unknown".to_string(),
|bin| bin.path.as_path().to_string_lossy().to_string()
),
BINARY = info
.binary
.as_ref()
.map_or_else(|| "Unknown".to_string(), |binary| format!("{binary:#?}")),
WORKSPACE_FOLDERS = info.workspace_folders.join(", "),
CAPABILITIES = serde_json::to_string_pretty(&info.capabilities)
.unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")),
@@ -990,10 +990,16 @@ impl Render for LspLogToolbarItemView {
let server_id = server.server_id;
let rpc_trace_enabled = server.rpc_trace_enabled;
let log_view = log_view.clone();
let label = match server.selected_entry {
LogKind::Rpc => RPC_MESSAGES,
LogKind::Trace => SERVER_TRACE,
LogKind::Logs => SERVER_LOGS,
LogKind::ServerInfo => SERVER_INFO,
};
PopoverMenu::new("LspViewSelector")
.anchor(Corner::TopLeft)
.trigger(
Button::new("language_server_menu_header", server.selected_entry.label())
Button::new("language_server_menu_header", label)
.icon(IconName::ChevronDown)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),

View File

@@ -68,6 +68,12 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement
MODE_NAMES[mode as usize].clone(),
move |_, _, cx| {
write_mode_change(mode, cx);
telemetry::event!(
"Welcome Theme mode Changed",
from = theme_mode,
to = mode
);
},
)
}),
@@ -105,7 +111,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement
ThemeMode::Dark => Appearance::Dark,
ThemeMode::System => *system_appearance,
};
let current_theme_name = theme_selection.theme(appearance);
let current_theme_name = SharedString::new(theme_selection.theme(appearance));
let theme_names = match appearance {
Appearance::Light => LIGHT_THEMES,
@@ -149,8 +155,15 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement
})
.on_click({
let theme_name = theme.name.clone();
let current_theme_name = current_theme_name.clone();
move |_, _, cx| {
write_theme_change(theme_name.clone(), theme_mode, cx);
telemetry::event!(
"Welcome Theme Changed",
from = current_theme_name,
to = theme_name
);
}
})
.map(|this| {
@@ -239,6 +252,17 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement
cx,
move |setting, _| setting.metrics = Some(enabled),
);
// This telemetry event shouldn't fire when it's off. If it does we're be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!("Welcome Page Telemetry Metrics Toggled",
options = if enabled {
"on"
} else {
"off"
}
);
}},
).tab_index({
*tab_index += 1;
@@ -267,6 +291,16 @@ fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement
cx,
move |setting, _| setting.diagnostics = Some(enabled),
);
// This telemetry event shouldn't fire when it's off. If it does we're be alerted
// and can fix it in a timely manner to respect a user's choice.
telemetry::event!("Welcome Page Telemetry Diagnostics Toggled",
options = if enabled {
"on"
} else {
"off"
}
);
}
}
).tab_index({
@@ -327,6 +361,8 @@ fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoE
update_settings_file::<BaseKeymap>(fs, cx, move |setting, _| {
setting.base_keymap = Some(keymap_base);
});
telemetry::event!("Welcome Keymap Changed", keymap = keymap_base);
}
}
@@ -344,13 +380,21 @@ fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoEleme
{
let fs = <dyn Fs>::global(cx);
move |&selection, _, cx| {
update_settings_file::<VimModeSetting>(fs.clone(), cx, move |setting, _| {
*setting = match selection {
ToggleState::Selected => Some(true),
ToggleState::Unselected => Some(false),
ToggleState::Indeterminate => None,
let vim_mode = match selection {
ToggleState::Selected => true,
ToggleState::Unselected => false,
ToggleState::Indeterminate => {
return;
}
};
update_settings_file::<VimModeSetting>(fs.clone(), cx, move |setting, _| {
*setting = Some(vim_mode);
});
telemetry::event!(
"Welcome Vim Mode Toggled",
options = if vim_mode { "on" } else { "off" },
);
}
},
)

View File

@@ -449,28 +449,28 @@ impl FontPickerDelegate {
) -> Self {
let font_family_cache = FontFamilyCache::global(cx);
let fonts: Vec<SharedString> = font_family_cache
.list_font_families(cx)
.into_iter()
.collect();
let fonts = font_family_cache
.try_list_font_families()
.unwrap_or_else(|| vec![current_font.clone()]);
let selected_index = fonts
.iter()
.position(|font| *font == current_font)
.unwrap_or(0);
let filtered_fonts = fonts
.iter()
.enumerate()
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect();
Self {
fonts: fonts.clone(),
filtered_fonts: fonts
.iter()
.enumerate()
.map(|(index, font)| StringMatch {
candidate_id: index,
string: font.to_string(),
positions: Vec::new(),
score: 0.0,
})
.collect(),
fonts,
filtered_fonts,
selected_index,
current_font,
on_font_changed: Arc::new(on_font_changed),

View File

@@ -242,12 +242,25 @@ struct Onboarding {
impl Onboarding {
fn new(workspace: &Workspace, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self {
workspace: workspace.weak_handle(),
focus_handle: cx.focus_handle(),
selected_page: SelectedPage::Basics,
user_store: workspace.user_store().clone(),
_settings_subscription: cx.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
let font_family_cache = theme::FontFamilyCache::global(cx);
cx.new(|cx| {
cx.spawn(async move |this, cx| {
font_family_cache.prefetch(cx).await;
this.update(cx, |_, cx| {
cx.notify();
})
})
.detach();
Self {
workspace: workspace.weak_handle(),
focus_handle: cx.focus_handle(),
selected_page: SelectedPage::Basics,
user_store: workspace.user_store().clone(),
_settings_subscription: cx
.observe_global::<SettingsStore>(move |_, cx| cx.notify()),
}
})
}
@@ -476,6 +489,7 @@ impl Onboarding {
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
telemetry::event!("Welcome Sign In Clicked");
window.dispatch_action(SignIn.boxed_clone(), cx);
})
.into_any_element()

View File

@@ -16,11 +16,6 @@ const SEND_LINE: &str = "\n// Send:";
const RECEIVE_LINE: &str = "\n// Receive:";
const MAX_STORED_LOG_ENTRIES: usize = 2000;
const RPC_MESSAGES: &str = "RPC Messages";
const SERVER_LOGS: &str = "Server Logs";
const SERVER_TRACE: &str = "Server Trace";
const SERVER_INFO: &str = "Server Info";
pub fn init(on_headless_host: bool, cx: &mut App) -> Entity<LogStore> {
let log_store = cx.new(|cx| LogStore::new(on_headless_host, cx));
cx.set_global(GlobalLogStore(log_store.clone()));
@@ -216,15 +211,6 @@ impl LogKind {
LanguageServerLogType::Rpc { .. } => Self::Rpc,
}
}
pub fn label(&self) -> &'static str {
match self {
LogKind::Rpc => RPC_MESSAGES,
LogKind::Trace => SERVER_TRACE,
LogKind::Logs => SERVER_LOGS,
LogKind::ServerInfo => SERVER_INFO,
}
}
}
impl LogStore {

View File

@@ -49,14 +49,6 @@ impl Project {
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let is_via_remote = self.remote_client.is_some();
let project_path_context = self
.active_entry()
.and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
.or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(Path::new("")),
});
let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
if is_via_remote {
@@ -124,23 +116,42 @@ impl Project {
},
};
let toolchain = project_path_context
let project_path_contexts = self
.active_entry()
.and_then(|entry_id| self.path_for_entry(entry_id, cx))
.into_iter()
.chain(
self.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(Path::new("")),
}),
);
let toolchains = project_path_contexts
.filter(|_| detect_venv)
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
.collect::<Vec<_>>();
let lang_registry = self.languages.clone();
let fs = self.fs.clone();
cx.spawn(async move |project, cx| {
let activation_script = maybe!(async {
let toolchain = toolchain?.await?;
Some(
lang_registry
for toolchain in toolchains {
let Some(toolchain) = toolchain.await else {
continue;
};
let language = lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok()?
.toolchain_lister()?
.activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
.await,
)
.ok();
let lister = language?.toolchain_lister();
return Some(
lister?
.activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
.await,
);
}
None
})
.await
.unwrap_or_default();
@@ -268,14 +279,6 @@ impl Project {
cwd: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let project_path_context = self
.active_entry()
.and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
.or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(Path::new("")),
});
let path = cwd.map(|p| Arc::from(&*p));
let is_via_remote = self.remote_client.is_some();
@@ -303,9 +306,22 @@ impl Project {
let local_path = if is_via_remote { None } else { path.clone() };
let toolchain = project_path_context
let project_path_contexts = self
.active_entry()
.and_then(|entry_id| self.path_for_entry(entry_id, cx))
.into_iter()
.chain(
self.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(Path::new("")),
}),
);
let toolchains = project_path_contexts
.filter(|_| detect_venv)
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
.collect::<Vec<_>>();
let remote_client = self.remote_client.clone();
let shell = match &remote_client {
Some(remote_client) => remote_client
@@ -327,17 +343,22 @@ impl Project {
let fs = self.fs.clone();
cx.spawn(async move |project, cx| {
let activation_script = maybe!(async {
let toolchain = toolchain?.await?;
let language = lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok();
let lister = language?.toolchain_lister();
Some(
lister?
.activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
.await,
)
for toolchain in toolchains {
let Some(toolchain) = toolchain.await else {
continue;
};
let language = lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok();
let lister = language?.toolchain_lister();
return Some(
lister?
.activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
.await,
);
}
None
})
.await
.unwrap_or_default();

View File

@@ -254,7 +254,7 @@ impl RemoteConnection for SshRemoteConnection {
let ssh_proxy_process = match self
.socket
.ssh_command("sh", &["-lc", &start_proxy_command])
.ssh_command("sh", &["-c", &start_proxy_command])
// IMPORTANT: we kill this process when we drop the task that uses it.
.kill_on_drop(true)
.spawn()
@@ -529,7 +529,7 @@ impl SshRemoteConnection {
.run_command(
"sh",
&[
"-lc",
"-c",
&shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
],
)
@@ -607,7 +607,7 @@ impl SshRemoteConnection {
.run_command(
"sh",
&[
"-lc",
"-c",
&shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
],
)
@@ -655,7 +655,7 @@ impl SshRemoteConnection {
dst_path = &dst_path.to_string()
)
};
self.socket.run_command("sh", &["-lc", &script]).await?;
self.socket.run_command("sh", &["-c", &script]).await?;
Ok(())
}
@@ -797,7 +797,7 @@ impl SshSocket {
}
async fn platform(&self) -> Result<RemotePlatform> {
let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?;
let uname = self.run_command("sh", &["-c", "uname -sm"]).await?;
let Some((os, arch)) = uname.split_once(" ") else {
anyhow::bail!("unknown uname: {uname:?}")
};
@@ -828,7 +828,7 @@ impl SshSocket {
}
async fn shell(&self) -> String {
match self.run_command("sh", &["-lc", "echo $SHELL"]).await {
match self.run_command("sh", &["-c", "echo $SHELL"]).await {
Ok(shell) => shell.trim().to_owned(),
Err(e) => {
log::error!("Failed to get shell: {e}");

View File

@@ -73,6 +73,7 @@ impl SettingsValue<serde_json::Value> {
let fs = <dyn Fs>::global(cx);
let rx = settings_store.update_settings_file_at_path(fs.clone(), path.as_slice(), value);
let path = path.clone();
cx.background_spawn(async move {
rx.await?

View File

@@ -14,6 +14,7 @@ collections.workspace = true
futures.workspace = true
indoc.workspace = true
libsqlite3-sys.workspace = true
log.workspace = true
parking_lot.workspace = true
smol.workspace = true
sqlformat.workspace = true

View File

@@ -59,6 +59,7 @@ impl Connection {
let mut store_completed_migration = self
.exec_bound("INSERT INTO migrations (domain, step, migration) VALUES (?, ?, ?)")?;
let mut did_migrate = false;
for (index, migration) in migrations.iter().enumerate() {
let migration =
sqlformat::format(migration, &sqlformat::QueryParams::None, Default::default());
@@ -70,9 +71,7 @@ impl Connection {
&sqlformat::QueryParams::None,
Default::default(),
);
if completed_migration == migration
|| migration.trim().starts_with("-- ALLOW_MIGRATION_CHANGE")
{
if completed_migration == migration {
// Migration already run. Continue
continue;
} else if should_allow_migration_change(index, &completed_migration, &migration)
@@ -91,12 +90,58 @@ impl Connection {
}
self.eager_exec(&migration)?;
did_migrate = true;
store_completed_migration((domain, index, migration))?;
}
if did_migrate {
self.delete_rows_with_orphaned_foreign_key_references()?;
self.exec("PRAGMA foreign_key_check;")?()?;
}
Ok(())
})
}
/// Delete any rows that were orphaned by a migration. This is needed
/// because we disable foreign key constraints during migrations, so
/// that it's possible to re-create a table with the same name, without
/// deleting all associated data.
fn delete_rows_with_orphaned_foreign_key_references(&self) -> Result<()> {
let foreign_key_info: Vec<(String, String, String, String)> = self.select(
r#"
SELECT DISTINCT
schema.name as child_table,
foreign_keys.[from] as child_key,
foreign_keys.[table] as parent_table,
foreign_keys.[to] as parent_key
FROM sqlite_schema schema
JOIN pragma_foreign_key_list(schema.name) foreign_keys
WHERE
schema.type = 'table' AND
schema.name NOT LIKE "sqlite_%"
"#,
)?()?;
if !foreign_key_info.is_empty() {
log::info!(
"Found {} foreign key relationships to check",
foreign_key_info.len()
);
}
for (child_table, child_key, parent_table, parent_key) in foreign_key_info {
self.exec(&format!(
"
DELETE FROM {child_table}
WHERE {child_key} IS NOT NULL and {child_key} NOT IN
(SELECT {parent_key} FROM {parent_table})
"
))?()?;
}
Ok(())
}
}
#[cfg(test)]

View File

@@ -95,6 +95,14 @@ impl<M: Migrator> ThreadSafeConnectionBuilder<M> {
let mut migration_result =
anyhow::Result::<()>::Err(anyhow::anyhow!("Migration never run"));
let foreign_keys_enabled: bool =
connection.select_row::<i32>("PRAGMA foreign_keys")?()
.unwrap_or(None)
.map(|enabled| enabled != 0)
.unwrap_or(false);
connection.exec("PRAGMA foreign_keys = OFF;")?()?;
for _ in 0..MIGRATION_RETRIES {
migration_result = connection
.with_savepoint("thread_safe_multi_migration", || M::migrate(connection));
@@ -104,6 +112,9 @@ impl<M: Migrator> ThreadSafeConnectionBuilder<M> {
}
}
if foreign_keys_enabled {
connection.exec("PRAGMA foreign_keys = ON;")?()?;
}
migration_result
})
.await?;

View File

@@ -2,6 +2,7 @@ use std::{cmp::Ordering, fmt::Debug};
use crate::{Bias, Dimension, Edit, Item, KeyedItem, SeekTarget, SumTree, Summary};
/// A cheaply-cloneable ordered map based on a [SumTree](crate::SumTree).
#[derive(Clone, PartialEq, Eq)]
pub struct TreeMap<K, V>(SumTree<MapEntry<K, V>>)
where

View File

@@ -1192,8 +1192,8 @@ impl Element for TerminalElement {
bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top);
let marked_text_cloned: Option<String> = {
let ime_state = self.terminal_view.read(cx);
ime_state.marked_text.clone()
let ime_state = &self.terminal_view.read(cx).ime_state;
ime_state.as_ref().map(|state| state.marked_text.clone())
};
let terminal_input_handler = TerminalInputHandler {
@@ -1421,11 +1421,9 @@ impl InputHandler for TerminalInputHandler {
_window: &mut Window,
cx: &mut App,
) {
if let Some(range) = new_marked_range {
self.terminal_view.update(cx, |view, view_cx| {
view.set_marked_text(new_text.to_string(), range, view_cx);
});
}
self.terminal_view.update(cx, |view, view_cx| {
view.set_marked_text(new_text.to_string(), new_marked_range, view_cx);
});
}
fn unmark_text(&mut self, _window: &mut Window, cx: &mut App) {

View File

@@ -62,6 +62,11 @@ use std::{
time::Duration,
};
struct ImeState {
marked_text: String,
marked_range_utf16: Option<Range<usize>>,
}
const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
const TERMINAL_SCROLLBAR_WIDTH: Pixels = px(12.);
@@ -138,8 +143,7 @@ pub struct TerminalView {
scroll_handle: TerminalScrollHandle,
show_scrollbar: bool,
hide_scrollbar_task: Option<Task<()>>,
marked_text: Option<String>,
marked_range_utf16: Option<Range<usize>>,
ime_state: Option<ImeState>,
_subscriptions: Vec<Subscription>,
_terminal_subscriptions: Vec<Subscription>,
}
@@ -263,8 +267,7 @@ impl TerminalView {
show_scrollbar: !Self::should_autohide_scrollbar(cx),
hide_scrollbar_task: None,
cwd_serialized: false,
marked_text: None,
marked_range_utf16: None,
ime_state: None,
_subscriptions: vec![
focus_in,
focus_out,
@@ -323,24 +326,27 @@ impl TerminalView {
pub(crate) fn set_marked_text(
&mut self,
text: String,
range: Range<usize>,
range: Option<Range<usize>>,
cx: &mut Context<Self>,
) {
self.marked_text = Some(text);
self.marked_range_utf16 = Some(range);
self.ime_state = Some(ImeState {
marked_text: text,
marked_range_utf16: range,
});
cx.notify();
}
/// Gets the current marked range (UTF-16).
pub(crate) fn marked_text_range(&self) -> Option<Range<usize>> {
self.marked_range_utf16.clone()
self.ime_state
.as_ref()
.and_then(|state| state.marked_range_utf16.clone())
}
/// Clears the marked (pre-edit) text state.
pub(crate) fn clear_marked_text(&mut self, cx: &mut Context<Self>) {
if self.marked_text.is_some() {
self.marked_text = None;
self.marked_range_utf16 = None;
if self.ime_state.is_some() {
self.ime_state = None;
cx.notify();
}
}

View File

@@ -16,7 +16,7 @@ struct FontFamilyCacheState {
/// so we do it once and then use the cached values each render.
#[derive(Default)]
pub struct FontFamilyCache {
state: RwLock<FontFamilyCacheState>,
state: Arc<RwLock<FontFamilyCacheState>>,
}
#[derive(Default)]
@@ -52,4 +52,44 @@ impl FontFamilyCache {
lock.font_families.clone()
}
/// Returns the list of font families if they have been loaded
pub fn try_list_font_families(&self) -> Option<Vec<SharedString>> {
self.state
.try_read()
.filter(|state| state.loaded_at.is_some())
.map(|state| state.font_families.clone())
}
/// Prefetch all font names in the background
pub async fn prefetch(&self, cx: &gpui::AsyncApp) {
if self
.state
.try_read()
.is_none_or(|state| state.loaded_at.is_some())
{
return;
}
let Ok(text_system) = cx.update(|cx| App::text_system(cx).clone()) else {
return;
};
let state = self.state.clone();
cx.background_executor()
.spawn(async move {
// We take this lock in the background executor to ensure that synchronous calls to `list_font_families` are blocked while we are prefetching,
// while not blocking the main thread and risking deadlocks
let mut lock = state.write();
let all_font_names = text_system
.all_font_names()
.into_iter()
.map(SharedString::from)
.collect();
lock.font_families = all_font_names;
lock.loaded_at = Some(Instant::now());
})
.await;
}
}

View File

@@ -253,8 +253,9 @@ pub(crate) struct UiFontSize(Pixels);
impl Global for UiFontSize {}
/// In-memory override for the font size in the agent panel.
#[derive(Default)]
pub(crate) struct AgentFontSize(Pixels);
pub struct AgentFontSize(Pixels);
impl Global for AgentFontSize {}

View File

@@ -7,6 +7,7 @@ pub struct OnboardingBanner {
dismissed: bool,
source: String,
details: BannerDetails,
visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
}
#[derive(Clone)]
@@ -42,12 +43,18 @@ impl OnboardingBanner {
label: label.into(),
subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
},
visible_when: None,
dismissed: get_dismissed(source),
}
}
fn should_show(&self, _cx: &mut App) -> bool {
!self.dismissed
pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
self.visible_when = Some(Box::new(predicate));
self
}
fn should_show(&self, cx: &mut App) -> bool {
!self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
}
fn dismiss(&mut self, cx: &mut Context<Self>) {

View File

@@ -279,13 +279,15 @@ impl TitleBar {
let banner = cx.new(|cx| {
OnboardingBanner::new(
"ACP Onboarding",
IconName::Sparkle,
"Bring Your Own Agent",
"ACP Claude Code Onboarding",
IconName::AiClaude,
"Claude Code",
Some("Introducing:".into()),
zed_actions::agent::OpenAcpOnboardingModal.boxed_clone(),
zed_actions::agent::OpenClaudeCodeOnboardingModal.boxed_clone(),
cx,
)
// When updating this to a non-AI feature release, remove this line.
.visible_when(|cx| !project::DisableAiSettings::get_global(cx).disable_ai)
});
let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));

View File

@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor."
edition.workspace = true
name = "zed"
version = "0.203.0"
version = "0.203.4"
publish.workspace = true
license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"]

View File

@@ -1 +1 @@
dev
stable

View File

@@ -1317,15 +1317,31 @@ pub fn handle_keymap_file_changes(
})
.detach();
let mut current_layout_id = cx.keyboard_layout().id().to_string();
cx.on_keyboard_layout_change(move |cx| {
let next_layout_id = cx.keyboard_layout().id();
if next_layout_id != current_layout_id {
current_layout_id = next_layout_id.to_string();
keyboard_layout_tx.unbounded_send(()).ok();
}
})
.detach();
#[cfg(target_os = "windows")]
{
let mut current_layout_id = cx.keyboard_layout().id().to_string();
cx.on_keyboard_layout_change(move |cx| {
let next_layout_id = cx.keyboard_layout().id();
if next_layout_id != current_layout_id {
current_layout_id = next_layout_id.to_string();
keyboard_layout_tx.unbounded_send(()).ok();
}
})
.detach();
}
#[cfg(not(target_os = "windows"))]
{
let mut current_mapping = cx.keyboard_mapper().get_key_equivalents().cloned();
cx.on_keyboard_layout_change(move |cx| {
let next_mapping = cx.keyboard_mapper().get_key_equivalents();
if current_mapping.as_ref() != next_mapping {
current_mapping = next_mapping.cloned();
keyboard_layout_tx.unbounded_send(()).ok();
}
})
.detach();
}
load_default_keymap(cx);

View File

@@ -286,6 +286,8 @@ pub mod agent {
OpenOnboardingModal,
/// Opens the ACP onboarding modal.
OpenAcpOnboardingModal,
/// Opens the Claude Code onboarding modal.
OpenClaudeCodeOnboardingModal,
/// Resets the agent onboarding state.
ResetOnboarding,
/// Starts a chat conversation with the agent.