Merge remote-tracking branch 'origin/main'

# Conflicts:
#	Cargo.lock
#	crates/settings_content/src/settings_content.rs
#	crates/zed/Cargo.toml
#	script/bundle-linux
This commit is contained in:
yenru0 committed 2026-09-22 13:17:50 +09:00
commit f1ba3ea49f
775 files changed
+86177 -17521

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
[dependencies]
action_log.workspace = true
agent_settings.workspace = true
agent-client-protocol.workspace = true
async-channel.workspace = true
base64.workspace = true
@@ -59,6 +60,7 @@ urlencoding.workspace = true
env_logger.workspace = true
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
markdown = { workspace = true, features = ["test-support"] }
parking_lot.workspace = true
project = { workspace = true, "features" = ["test-support"] }
rand.workspace = true
File diff suppressed because it is too large. Load diff
+112 -32
View File
@@ -1,5 +1,5 @@
use agent_client_protocol::schema::v1 as acp;
use anyhow::Result;
use anyhow::{Result, bail};
use collections::HashMap;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, AsyncApp, Context, Entity, Task};
@@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize};
use std::{
collections::HashMap as StdHashMap,
path::PathBuf,
process::ExitStatus,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
@@ -407,7 +406,7 @@ pub struct Terminal {
started_at: Instant,
output: Option<TerminalOutput>,
output_byte_limit: Option<usize>,
_output_task: Shared<Task<acp::TerminalExitStatus>>,
execution: TerminalExecution,
/// Flag indicating whether this terminal was stopped by explicit user action
/// (e.g., clicking the Stop button). This is set before kill() is called
/// so that code awaiting wait_for_exit() can check it deterministically.
@@ -419,9 +418,14 @@ pub struct Terminal {
_sandbox: Option<SandboxConfigHandle>,
}
enum TerminalExecution {
Process(Shared<Task<acp::TerminalExitStatus>>),
Display,
}
pub struct TerminalOutput {
pub ended_at: Instant,
pub exit_status: Option<ExitStatus>,
pub exit_status: acp::TerminalExitStatus,
pub content: String,
pub original_content_len: usize,
pub content_line_count: usize,
@@ -469,21 +473,17 @@ impl Terminal {
output: None,
output_byte_limit,
user_stopped: Arc::new(AtomicBool::new(false)),
_output_task: cx
.spawn(async move |this, cx| {
let exit_status = command_task.await;
execution: TerminalExecution::Process(
cx.spawn(async move |this, cx| {
let exit_status = command_task.await.map(portable_pty::ExitStatus::from);
let exit_status = acp::TerminalExitStatus::new()
.exit_code(exit_status.as_ref().map(|status| status.exit_code()))
.signal(
exit_status.and_then(|status| status.signal().map(ToOwned::to_owned)),
);
this.update(cx, |this, cx| {
let (content, original_content_len) = this.truncated_output(cx);
let content_line_count = this.terminal.read(cx).total_lines();
this.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
content,
original_content_len,
content_line_count,
});
this.cache_output(exit_status.clone(), Instant::now(), cx);
this.terminal.update(cx, |terminal, _cx| {
terminal.release_pty_resources();
});
@@ -497,17 +497,43 @@ impl Terminal {
.spawn(async move { sandbox.drop_on_current_thread() })
.detach();
}
cx.notify();
})
.ok();
let exit_status = exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalExitStatus::new()
.exit_code(exit_status.as_ref().map(|e| e.exit_code()))
.signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned)))
exit_status
})
.shared(),
),
}
}
pub fn new_display(
id: acp::TerminalId,
command_label: &str,
working_dir: Option<PathBuf>,
output_byte_limit: Option<usize>,
terminal: Entity<terminal::Terminal>,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Self {
Self {
id,
command: cx.new(|cx| {
Markdown::new(
format!("```\n{}\n```", command_label).into(),
Some(language_registry),
None,
cx,
)
}),
working_dir,
terminal,
started_at: Instant::now(),
output: None,
output_byte_limit,
execution: TerminalExecution::Display,
user_stopped: Arc::new(AtomicBool::new(false)),
_sandbox: None,
}
}
@@ -515,11 +541,24 @@ impl Terminal {
&self.id
}
pub fn wait_for_exit(&self) -> Shared<Task<acp::TerminalExitStatus>> {
self._output_task.clone()
/// Ownership is independent of the renderer's PTY: headless tasks still own a process.
pub fn is_process_backed(&self) -> bool {
matches!(self.execution, TerminalExecution::Process(_))
}
pub fn wait_for_exit(&self) -> Result<Shared<Task<acp::TerminalExitStatus>>> {
match &self.execution {
TerminalExecution::Process(task) => Ok(task.clone()),
TerminalExecution::Display => {
bail!("Agent-provided terminals have no client-owned process to wait for")
}
}
}
pub fn kill(&mut self, cx: &mut App) {
if !self.is_process_backed() {
return;
}
self.terminal.update(cx, |terminal, _cx| {
terminal.kill_active_task();
});
@@ -528,6 +567,9 @@ impl Terminal {
/// Marks this terminal as stopped by user action and then kills it.
/// This should be called when the user explicitly clicks a Stop button.
pub fn stop_by_user(&mut self, cx: &mut App) {
if !self.is_process_backed() {
return;
}
self.user_stopped.store(true, Ordering::SeqCst);
self.kill(cx);
}
@@ -539,17 +581,11 @@ impl Terminal {
pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse {
if let Some(output) = self.output.as_ref() {
let exit_status = output.exit_status.map(portable_pty::ExitStatus::from);
acp::TerminalOutputResponse::new(
output.content.clone(),
output.original_content_len > output.content.len(),
)
.exit_status(
acp::TerminalExitStatus::new()
.exit_code(exit_status.as_ref().map(|e| e.exit_code()))
.signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))),
)
.exit_status(output.exit_status.clone())
} else {
let (current_content, original_len) = self.truncated_output(cx);
let truncated = current_content.len() < original_len;
@@ -557,6 +593,50 @@ impl Terminal {
}
}
pub(crate) fn write_display_output(&mut self, data: &[u8], cx: &mut Context<Self>) {
if self.is_process_backed() {
return;
}
self.terminal
.update(cx, |terminal, cx| terminal.write_output(data, cx));
if let Some(output) = &self.output {
// A process may exit before its final output arrives. Refresh the
// cached content without changing its completion time or status.
self.cache_output(output.exit_status.clone(), output.ended_at, cx);
}
}
pub(crate) fn finish_display(
&mut self,
exit_status: acp::TerminalExitStatus,
cx: &mut Context<Self>,
) {
if self.is_process_backed() || self.output.is_some() {
return;
}
self.terminal
.update(cx, |terminal, _| terminal.shrink_to_used());
self.cache_output(exit_status, Instant::now(), cx);
}
fn cache_output(
&mut self,
exit_status: acp::TerminalExitStatus,
ended_at: Instant,
cx: &mut Context<Self>,
) {
let (content, original_content_len) = self.truncated_output(cx);
let content_line_count = self.terminal.read(cx).total_lines();
self.output = Some(TerminalOutput {
ended_at,
exit_status,
content,
original_content_len,
content_line_count,
});
cx.notify();
}
fn truncated_output(&self, cx: &App) -> (String, usize) {
let terminal = self.terminal.read(cx);
let mut content = terminal.get_content();
+1
View File
@@ -12,6 +12,7 @@ workspace = true
[lib]
path = "src/acp_tools.rs"
doctest = false
test = false
[features]
test-support = ["workspace/test-support"]
+1
View File
@@ -7,6 +7,7 @@ license = "GPL-3.0-or-later"
[lib]
path = "src/action_log.rs"
doctest = false
[lints]
workspace = true
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[lib]
path = "src/activity_indicator.rs"
doctest = false
test = false
[features]
test-support = []
@@ -7,8 +7,7 @@ use gpui::{
SharedString, Styled, Task, Window, actions,
};
use language::{
BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
LanguageServerStatusUpdate, ServerHealth,
BinaryStatus, LanguageServerId, LanguageServerName, LanguageServerStatusUpdate, ServerHealth,
};
use project::{
LanguageServerProgress, LspStoreEvent, ProgressToken, Project, ProjectEnvironmentEvent,
@@ -91,28 +90,11 @@ struct Content {
impl ActivityIndicator {
pub fn new(
workspace: &mut Workspace,
languages: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<ActivityIndicator> {
let project = workspace.project().clone();
let this = cx.new(|cx| {
let mut status_events = languages.language_server_binary_statuses();
cx.spawn(async move |this, cx| {
while let Some((name, binary_status)) = status_events.next().await {
this.update(cx, |this: &mut ActivityIndicator, cx| {
this.statuses.retain(|s| s.name != name);
this.statuses.push(ServerStatus {
name,
status: LanguageServerStatusUpdate::Binary(binary_status),
});
cx.notify();
})?;
}
anyhow::Ok(())
})
.detach();
let fs = project.read(cx).fs().clone();
let mut job_events = fs.subscribe_to_jobs();
cx.spawn(async move |this, cx| {
@@ -120,11 +102,17 @@ impl ActivityIndicator {
this.update(cx, |this: &mut ActivityIndicator, cx| {
match job_event {
fs::JobEvent::Started { info } => {
this.fs_jobs.retain(|j| j.id != info.id);
this.fs_jobs.retain(|job| job.id != info.id);
this.fs_jobs.push(info);
}
fs::JobEvent::Updated { id, message } => {
if let Some(job) = this.fs_jobs.iter_mut().find(|job| job.id == id)
{
job.message = message;
}
}
fs::JobEvent::Completed { id } => {
this.fs_jobs.retain(|j| j.id != id);
this.fs_jobs.retain(|job| job.id != id);
}
}
cx.notify();
+1
View File
@@ -7,6 +7,7 @@ license = "GPL-3.0-or-later"
[lib]
path = "src/agent.rs"
doctest = false
[features]
test-support = ["db/test-support"]
+882 -230
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+574 -227
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -590,6 +590,7 @@ mod tests {
profiles: Default::default(),
notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
play_sound_when_agent_done: PlaySoundWhenAgentDone::default(),
prevent_idle_sleep: true,
single_file_review: false,
model_parameters: vec![],
auto_compact: agent_settings::AutoCompactSettings {
@@ -607,7 +608,11 @@ mod tests {
sandbox_permissions: Default::default(),
show_turn_stats: false,
show_merge_conflict_indicator: true,
sidebar_side: Default::default(),
threads_sidebar: agent_settings::ThreadsSidebarSettings {
auto_open: true,
default_width: px(300.),
position: settings::SidebarDockPosition::Left,
},
thinking_display: Default::default(),
}
}
+2
View File
@@ -234,6 +234,7 @@ mod tests {
#[gpui::test]
async fn test_ask_user_returns_selected_option(cx: &mut TestAppContext) {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let tool_call_id = event_stream.tool_call_id().clone();
let tool = Arc::new(AskUserTool);
let task = cx.update(|cx| {
tool.run(
@@ -248,6 +249,7 @@ mod tests {
});
let request = event_rx.expect_elicitation().await;
assert_eq!(request.tool_call_id, tool_call_id);
assert_eq!(request.message, "Which approach?");
assert!(request.schema.properties.contains_key(CHOICE_FIELD));
assert!(!request.schema.properties.contains_key(OTHER_FIELD));
+1
View File
@@ -81,6 +81,7 @@ impl AgentTool for RenameTool {
resolved.buffer.clone(),
resolved.position,
input.new_name.clone(),
None,
cx,
)
});
+242 -253
View File
@@ -7,7 +7,9 @@ use agent_client_protocol::schema::{
ProtocolVersion,
v1::{self as acp, ErrorCode},
};
use agent_client_protocol::{Agent, Client, ConnectionTo, JsonRpcResponse, Lines, Responder};
use agent_client_protocol::{
Agent, Builder, Client, ConnectionTo, HandleDispatchFrom, JsonRpcResponse, Lines, Responder,
};
use anyhow::anyhow;
use async_channel;
use collections::{HashMap, HashSet};
@@ -491,7 +493,6 @@ impl AcpConnectionDefaults {
struct PendingAcpSession {
task: Shared<Task<Result<Entity<AcpThread>, Arc<anyhow::Error>>>>,
ref_count: usize,
}
struct SessionConfigResponse {
@@ -522,7 +523,7 @@ pub struct AcpSession {
suppress_abort_err: bool,
session_modes: Option<Rc<RefCell<acp::SessionModeState>>>,
config_options: Option<ConfigOptions>,
ref_count: usize,
_release_subscription: Subscription,
}
pub struct AcpSessionList {
@@ -662,42 +663,45 @@ pub async fn connect(
const MINIMUM_SUPPORTED_VERSION: ProtocolVersion = ProtocolVersion::V1;
/// Build a `Client` connection over `transport` with Zed's full
/// agent→client handler set wired up.
///
/// All incoming requests and notifications are forwarded to the foreground
/// dispatch queue via `dispatch_tx`, where they are handled by the
/// `handle_*` functions on a GPUI context. The returned future drives the
/// connection and completes when the transport closes; callers are expected
/// to poll it in the background and hold the task for the lifetime of the
/// connection. In unoptimized builds each inbound dispatch needs ~0.5 MiB
/// of stack, so poll it on a thread with room to spare (macOS GCD workers'
/// 512 KiB is not enough — see `AcpConnection::stdio`). The `connection_tx`
/// oneshot receives the `ConnectionTo<Agent>` handle as soon as the builder
/// runs its `main_fn`.
fn connect_client_future(
name: &'static str,
transport: impl agent_client_protocol::ConnectTo<Client> + 'static,
dispatch_tx: mpsc::UnboundedSender<ForegroundWork>,
connection_tx: futures::channel::oneshot::Sender<ConnectionTo<Agent>>,
) -> impl Future<Output = Result<(), acp::Error>> {
client_builder(name, dispatch_tx).connect_with(
transport,
move |connection: ConnectionTo<Agent>| async move {
if connection_tx.send(connection).is_err() {
log::error!("failed to send ACP connection handle — receiver was dropped");
}
// Keep the connection alive until the transport closes.
futures::future::pending::<Result<(), acp::Error>>().await
},
)
}
fn client_builder(
name: &'static str,
dispatch_sender: mpsc::UnboundedSender<ForegroundWork>,
) -> Builder<Client, impl HandleDispatchFrom<Agent>> {
// Each handler forwards its inputs onto the foreground dispatch queue.
// The SDK requires the closure to be `Send`, so we move a clone of
// `dispatch_tx` into each one.
macro_rules! on_request {
($handler:ident) => {{
let dispatch_tx = dispatch_tx.clone();
async move |req, responder, _connection| {
enqueue_request(&dispatch_tx, req, responder, $handler);
let dispatch_sender = dispatch_sender.clone();
async move |request, responder, _connection| {
enqueue_request(&dispatch_sender, request, responder, $handler);
Ok(())
}
}};
}
macro_rules! on_notification {
($handler:ident) => {{
let dispatch_tx = dispatch_tx.clone();
async move |notif, _connection| {
enqueue_notification(&dispatch_tx, notif, $handler);
let dispatch_sender = dispatch_sender.clone();
async move |notification, _connection| {
enqueue_notification(&dispatch_sender, notification, $handler);
Ok(())
}
}};
@@ -752,19 +756,12 @@ fn connect_client_future(
on_notification!(handle_complete_elicitation),
agent_client_protocol::on_receive_notification!(),
)
.connect_with(
transport,
move |connection: ConnectionTo<Agent>| async move {
if connection_tx.send(connection).is_err() {
log::error!("failed to send ACP connection handle — receiver was dropped");
}
// Keep the connection alive until the transport closes.
futures::future::pending::<Result<(), acp::Error>>().await
},
)
}
fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities {
fn client_capabilities_for_agent(
agent_id: &AgentId,
beta_features_enabled: bool,
) -> acp::ClientCapabilities {
let mut meta = acp::Meta::from_iter([
("terminal_output".into(), true.into()),
("terminal-auth".into(), true.into()),
@@ -774,18 +771,21 @@ fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities
meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into());
}
let mut session_capabilities = acp::ClientSessionCapabilities::new().config_options(
acp::SessionConfigOptionsCapabilities::new()
.boolean(acp::BooleanConfigOptionCapabilities::new()),
);
if beta_features_enabled {
session_capabilities = session_capabilities.compaction(acp::CompactionCapabilities::new());
}
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new()
.read_text_file(true)
.write_text_file(true))
.terminal(true)
.auth(acp::AuthCapabilities::new().terminal(true))
.session(
acp::ClientSessionCapabilities::new().config_options(
acp::SessionConfigOptionsCapabilities::new()
.boolean(acp::BooleanConfigOptionCapabilities::new()),
),
)
.session(session_capabilities)
.elicitation(
acp::ElicitationCapabilities::new()
.form(acp::ElicitationFormCapabilities::new())
@@ -927,26 +927,14 @@ impl AcpConnection {
}
});
// `connect_client_future` installs the production handler set and
// hands us back both the connection-future and a oneshot receiver
// that produces the `ConnectionTo<Agent>` once the transport
// handshake is ready. The future must be polled on a dedicated
// thread rather than via `background_spawn`: in unoptimized builds
// its dispatch chain needs ~0.5 MiB of stack per inbound message,
// which overflows the fixed 512 KiB stacks of the GCD workers that
// poll background tasks on macOS, crashing dev builds as soon as an
// agent sends its first message. See `spawn_dedicated` for the
// stack guarantee that makes the dedicated thread sufficient.
let (connection_tx, connection_rx) = futures::channel::oneshot::channel();
let connection_future =
connect_client_future("zed", transport, dispatch_tx.clone(), connection_tx);
let io_task = cx
.background_executor()
.spawn_dedicated(move |_executor| async move {
if let Err(err) = connection_future.await {
log::error!("ACP connection error: {err}");
}
});
let io_task = cx.background_spawn(async move {
if let Err(err) = connection_future.await {
log::error!("ACP connection error: {err}");
}
});
let connection_rx = async move {
connection_rx
@@ -988,10 +976,14 @@ impl AcpConnection {
}
});
let beta_features_enabled = cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>());
let initialize_response = connection
.send_request(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities_for_agent(&agent_id))
.client_capabilities(client_capabilities_for_agent(
&agent_id,
beta_features_enabled,
))
.client_info(
acp::Implementation::new("zed", version)
.title(release_channel.map(ToOwned::to_owned)),
@@ -1163,6 +1155,60 @@ impl AcpConnection {
session_directories_from_work_dirs(work_dirs, supports_additional_directories)
}
fn register_session(
&self,
session_id: acp::SessionId,
thread: &Entity<AcpThread>,
session_modes: Option<Rc<RefCell<acp::SessionModeState>>>,
config_options: Option<ConfigOptions>,
cx: &mut App,
) {
let release_subscription = cx.observe_release(thread, {
let session_id = session_id.clone();
let thread_id = thread.entity_id();
let sessions = self.sessions.clone();
let connection = self.connection.clone();
let supports_close = self.agent_supports_session_close();
move |_, cx| {
let removed = {
let mut sessions = sessions.borrow_mut();
match sessions.get(&session_id) {
Some(session) if session.thread.entity_id() == thread_id => {
sessions.remove(&session_id)
}
_ => None,
}
};
if removed.is_none() || !supports_close {
return;
}
cx.foreground_executor()
.spawn(async move {
connection
.send_request(acp::CloseSessionRequest::new(session_id))
.block_task()
.await
.log_err();
})
.detach();
}
});
self.sessions.borrow_mut().insert(
session_id,
AcpSession {
thread: thread.downgrade(),
suppress_abort_err: false,
session_modes,
config_options,
_release_subscription: release_subscription,
},
);
}
fn agent_supports_session_close(&self) -> bool {
self.agent_capabilities.session_capabilities.close.is_some()
}
fn open_or_create_session(
self: Rc<Self>,
session_id: acp::SessionId,
@@ -1184,19 +1230,20 @@ impl AcpConnection {
// Concurrent loads should still wait for the in-flight task so that
// ref-counting happens in one place and the caller sees a fully loaded
// session.
if let Some(pending) = self.pending_sessions.borrow_mut().get_mut(&session_id) {
pending.ref_count += 1;
if let Some(pending) = self.pending_sessions.borrow().get(&session_id) {
let task = pending.task.clone();
return cx
.foreground_executor()
.spawn(async move { task.await.map_err(|err| anyhow!(err)) });
}
if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) {
session.ref_count += 1;
if let Some(thread) = session.thread.upgrade() {
return Task::ready(Ok(thread));
}
if let Some(thread) = self
.sessions
.borrow()
.get(&session_id)
.and_then(|session| session.thread.upgrade())
{
return Task::ready(Ok(thread));
}
let directories = match self.session_directories_from_work_dirs(&work_dirs) {
@@ -1230,16 +1277,9 @@ impl AcpConnection {
// `session/update` notifications that arrive during the call
// (e.g. history replay during `session/load`) can find the thread.
// Modes/config are filled in once the response arrives.
this.sessions.borrow_mut().insert(
session_id.clone(),
AcpSession {
thread: thread.downgrade(),
suppress_abort_err: false,
session_modes: None,
config_options: None,
ref_count: 1,
},
);
cx.update(|cx| {
this.register_session(session_id.clone(), &thread, None, None, cx)
});
let response =
match rpc_call(this.connection.clone(), session_id.clone(), directories)
@@ -1260,17 +1300,8 @@ impl AcpConnection {
this.apply_default_config_options(&session_id, config_opts, cx);
}
let ref_count = this
.pending_sessions
.borrow_mut()
.remove(&session_id)
.map_or(1, |pending| pending.ref_count);
this.pending_sessions.borrow_mut().remove(&session_id);
// If `close_session` ran to completion while the load RPC was in
// flight, it will have removed both the pending entry and the
// sessions entry (and dispatched the ACP close RPC). In that case
// the thread has no live session to attach to, so fail the load
// instead of handing back an orphaned thread.
{
let mut sessions = this.sessions.borrow_mut();
let Some(session) = sessions.get_mut(&session_id) else {
@@ -1280,7 +1311,6 @@ impl AcpConnection {
};
session.session_modes = modes;
session.config_options = config_options.map(ConfigOptions::new);
session.ref_count = ref_count;
}
Ok(thread)
@@ -1292,7 +1322,6 @@ impl AcpConnection {
session_id,
PendingAcpSession {
task: shared_task.clone(),
ref_count: 1,
},
);
@@ -1567,12 +1596,7 @@ fn meta_terminal_auth_task(
env: HashMap<String, String>,
}
let meta = match method {
acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(),
acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(),
acp::AuthMethod::Agent(agent) => agent.meta.as_ref(),
_ => None,
}?;
let meta = method.meta()?;
let terminal_auth =
serde_json::from_value::<MetaTerminalAuth>(meta.get("terminal-auth")?.clone()).ok()?;
@@ -1693,16 +1717,15 @@ impl AgentConnection for AcpConnection {
)
});
self.sessions.borrow_mut().insert(
response.session_id,
AcpSession {
thread: thread.downgrade(),
suppress_abort_err: false,
session_modes: modes,
config_options: config_options.map(ConfigOptions::new),
ref_count: 1,
},
);
cx.update(|cx| {
self.register_session(
response.session_id,
&thread,
modes,
config_options.map(ConfigOptions::new),
cx,
)
});
Ok(thread)
})
@@ -1810,76 +1833,6 @@ impl AgentConnection for AcpConnection {
)
}
fn supports_close_session(&self) -> bool {
self.agent_capabilities.session_capabilities.close.is_some()
}
fn close_session(
self: Rc<Self>,
session_id: &acp::SessionId,
cx: &mut App,
) -> Task<Result<()>> {
if !self.supports_close_session() {
return Task::ready(Err(anyhow!(LoadError::Other(
"Closing sessions is not supported by this agent.".into()
))));
}
// If a load is still in flight, decrement its ref count. The pending
// entry is the source of truth for how many handles exist during a
// load, so we must tick it down here as well as the `sessions` entry
// that was pre-registered to receive history-replay notifications.
// Only once the pending ref count hits zero do we actually close the
// session; the load task will observe the missing sessions entry and
// fail with "session was closed before load completed".
let pending_ref_count = {
let mut pending_sessions = self.pending_sessions.borrow_mut();
pending_sessions.get_mut(session_id).map(|pending| {
pending.ref_count = pending.ref_count.saturating_sub(1);
pending.ref_count
})
};
match pending_ref_count {
Some(0) => {
self.pending_sessions.borrow_mut().remove(session_id);
self.sessions.borrow_mut().remove(session_id);
let conn = self.connection.clone();
let session_id = session_id.clone();
return cx.foreground_executor().spawn(async move {
conn.send_request(acp::CloseSessionRequest::new(session_id))
.block_task()
.await?;
Ok(())
});
}
Some(_) => return Task::ready(Ok(())),
None => {}
}
let mut sessions = self.sessions.borrow_mut();
let Some(session) = sessions.get_mut(session_id) else {
return Task::ready(Ok(()));
};
session.ref_count = session.ref_count.saturating_sub(1);
if session.ref_count > 0 {
return Task::ready(Ok(()));
}
sessions.remove(session_id);
drop(sessions);
let conn = self.connection.clone();
let session_id = session_id.clone();
cx.foreground_executor().spawn(async move {
conn.send_request(acp::CloseSessionRequest::new(session_id.clone()))
.block_task()
.await?;
Ok(())
})
}
fn auth_methods(&self) -> &[acp::AuthMethod] {
&self.auth_methods
}
@@ -1895,7 +1848,7 @@ impl AgentConnection for AcpConnection {
.find(|method| method.id() == method_id)?;
match method {
acp::AuthMethod::Terminal(terminal) if cx.has_flag::<AcpBetaFeatureFlag>() => {
acp::AuthMethod::Terminal(terminal) => {
let agent_id = self.id.clone();
let terminal = terminal.clone();
let store = self.agent_server_store.clone();
@@ -2225,6 +2178,7 @@ pub mod test_support {
pub connection: Rc<AcpConnection>,
pub load_session_count: Arc<AtomicUsize>,
pub close_session_count: Arc<AtomicUsize>,
pub authenticate_count: Arc<AtomicUsize>,
pub logout_count: Arc<AtomicUsize>,
pub keep_agent_alive: Task<anyhow::Result<()>>,
}
@@ -2274,18 +2228,6 @@ pub mod test_support {
.load_session(session_id, project, work_dirs, title, cx)
}
fn supports_close_session(&self) -> bool {
self.inner.supports_close_session()
}
fn close_session(
self: Rc<Self>,
session_id: &acp::SessionId,
cx: &mut App,
) -> Task<Result<()>> {
self.inner.clone().close_session(session_id, cx)
}
fn supports_resume_session(&self) -> bool {
self.inner.supports_resume_session()
}
@@ -2421,6 +2363,7 @@ pub mod test_support {
) -> Result<FakeAcpConnectionHarness> {
let (client_transport, agent_transport) = agent_client_protocol::Channel::duplex();
let authenticate_count = Arc::new(AtomicUsize::new(0));
let logout_count = Arc::new(AtomicUsize::new(0));
let sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>> =
Rc::new(RefCell::new(HashMap::default()));
@@ -2447,10 +2390,12 @@ pub mod test_support {
)
.on_receive_request(
{
let authenticate_count = authenticate_count.clone();
let auth_elicitation_request = auth_elicitation_request.clone();
let auth_elicitation_response = auth_elicitation_response.clone();
let auth_elicitation_completion = auth_elicitation_completion.clone();
async move |_req: acp::AuthenticateRequest, responder, cx| {
authenticate_count.fetch_add(1, Ordering::SeqCst);
let request = auth_elicitation_request
.lock()
.expect("auth elicitation request lock should not be poisoned")
@@ -2608,6 +2553,7 @@ pub mod test_support {
connection: Rc::new(connection),
load_session_count,
close_session_count,
authenticate_count,
logout_count,
keep_agent_alive,
})
@@ -2705,7 +2651,7 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use feature_flags::FeatureFlag as _;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _};
use settings::Settings as _;
fn init_feature_flags_test(cx: &mut gpui::TestAppContext) {
@@ -2722,7 +2668,7 @@ mod tests {
cx: &mut gpui::TestAppContext,
) {
init_feature_flags_test(cx);
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let elicitation = capabilities
.elicitation
.expect("elicitation should always be advertised");
@@ -3015,7 +2961,7 @@ mod tests {
#[test]
fn cursor_client_capabilities_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID));
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false);
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@@ -3030,7 +2976,7 @@ mod tests {
#[test]
fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@@ -3040,7 +2986,7 @@ mod tests {
#[test]
fn client_capabilities_include_boolean_config_options() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
assert!(
capabilities
@@ -3051,6 +2997,71 @@ mod tests {
);
}
#[test]
fn client_capabilities_only_include_compaction_with_acp_beta() {
for (beta_enabled, expected_compaction) in
[(false, None), (true, Some(serde_json::json!({})))]
{
let capabilities =
client_capabilities_for_agent(&AgentId::new("codex-acp"), beta_enabled);
let capabilities =
serde_json::to_value(capabilities).expect("client capabilities should serialize");
let session = capabilities
.get("session")
.expect("session capabilities should be advertised");
assert_eq!(session.get("compaction"), expected_compaction.as_ref());
}
}
#[gpui::test]
async fn connection_routes_terminal_auth_without_acp_beta(cx: &mut gpui::TestAppContext) {
init_feature_flags_test(cx);
let fs = fs::FakeFs::new(cx.executor());
fs.insert_tree("/", serde_json::json!({ "project": {} }))
.await;
let project = project::Project::test(fs, [std::path::Path::new("/project")], cx).await;
let mut harness = test_support::connect_fake_acp_connection(project, cx).await;
let method_id = acp::AuthMethodId::new("login");
let method = acp::AuthMethod::Terminal(
acp::AuthMethodTerminal::new(method_id.clone(), "First-class login")
.args(vec!["first-class-auth".into()])
.meta(acp::Meta::from_iter([(
"terminal-auth".to_string(),
serde_json::json!({
"label": "Legacy login",
"command": "legacy-agent",
"args": ["legacy-auth"],
}),
)])),
);
Rc::get_mut(&mut harness.connection)
.expect("test harness should have the only connection handle")
.auth_methods = vec![method];
let terminal_task = cx
.update(|cx| {
cx.update_flags(true, Vec::new());
feature_flags::FeatureFlagsSettings::override_global(
feature_flags::FeatureFlagsSettings {
overrides: HashMap::from_iter([(
AcpBetaFeatureFlag::NAME.into(),
"off".into(),
)]),
},
cx,
);
assert!(!cx.has_flag::<AcpBetaFeatureFlag>());
harness.connection.terminal_auth_task(&method_id, cx)
})
.expect("first-class terminal auth should be routed without ACP beta");
terminal_task
.await
.expect_err("first-class routing should resolve the test agent's external command");
assert_eq!(harness.authenticate_count.load(Ordering::SeqCst), 0);
}
#[test]
fn terminal_auth_task_builds_spawn_from_prebuilt_command() {
let command = AgentServerCommand {
@@ -4035,7 +4046,9 @@ mod tests {
}
#[gpui::test]
async fn test_loaded_sessions_keep_state_until_last_close(cx: &mut gpui::TestAppContext) {
async fn test_loaded_sessions_keep_state_until_last_handle_drops(
cx: &mut gpui::TestAppContext,
) {
let (
connection,
project,
@@ -4085,36 +4098,30 @@ mod tests {
"underlying ACP load_session should be called exactly once for concurrent loads"
);
// The session has ref_count 2. The first close should not send the ACP
// close_session RPC — the session is still referenced.
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.expect("first close failed");
drop(first_thread);
release_dropped_entities(cx);
assert_eq!(
close_count.load(Ordering::SeqCst),
0,
"ACP close_session should not be sent while ref_count > 0"
"ACP close_session should not be sent while a thread handle is alive"
);
assert!(
connection.sessions.borrow().contains_key(&session_id),
"session should still be tracked after first close"
"session should still be tracked while a thread handle is alive"
);
// The second close drops ref_count to 0 — now the ACP RPC must be sent.
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.expect("second close failed");
cx.run_until_parked();
drop(second_thread);
release_dropped_entities(cx);
assert_eq!(
close_count.load(Ordering::SeqCst),
1,
"ACP close_session should be sent exactly once when ref_count reaches 0"
"ACP close_session should be sent exactly once when the last handle drops"
);
assert!(
!connection.sessions.borrow().contains_key(&session_id),
"session should be removed after final close"
"session should be removed after the last handle drops"
);
}
@@ -4189,13 +4196,10 @@ mod tests {
);
}
// Regression test: if `close_session` is issued while a `load_session`
// RPC is still in flight, the close must take effect cleanly — the load
// must fail with a recognizable error (not return an orphaned thread),
// no entry must remain in `sessions` or `pending_sessions`, and the ACP
// `close_session` RPC must be dispatched.
#[gpui::test]
async fn test_close_session_during_in_flight_load(cx: &mut gpui::TestAppContext) {
async fn test_dropping_loader_during_in_flight_load_closes_session(
cx: &mut gpui::TestAppContext,
) {
let (
connection,
project,
@@ -4246,24 +4250,19 @@ mod tests {
"sessions entry should be pre-registered to receive replay notifications"
);
// Close the session while the load is still parked. This should take
// the pending path and dispatch the ACP close RPC.
let close_task = cx.update(|cx| connection.clone().close_session(&session_id, cx));
drop(load_task);
cx.run_until_parked();
assert_eq!(
close_count.load(Ordering::SeqCst),
0,
"ACP close_session must not be sent while the load RPC is in flight"
);
// Release the gate so the load RPC can finally respond.
gate_tx.send(()).await.expect("gate send failed");
drop(gate_tx);
let load_result = load_task.await;
close_task.await.expect("close failed");
cx.run_until_parked();
let err = load_result.expect_err("load should fail after close-during-load");
assert!(
err.to_string()
.contains("session was closed before load completed"),
"expected close-during-load error, got: {err}"
);
release_dropped_entities(cx);
assert_eq!(
close_count.load(Ordering::SeqCst),
@@ -4272,26 +4271,19 @@ mod tests {
);
assert!(
!connection.sessions.borrow().contains_key(&session_id),
"sessions entry should be removed after close-during-load"
"sessions entry should be removed once the loaded thread has no holders"
);
assert!(
!connection
.pending_sessions
.borrow()
.contains_key(&session_id),
"pending_sessions entry should be removed after close-during-load"
"pending_sessions entry should be removed once the load resolves"
);
}
// Regression test: when two concurrent `load_session` calls share a pending
// task and one of them issues `close_session` before the load RPC
// resolves, the remaining load must still succeed and the session must
// stay live. If `close_session` incorrectly short-circuits via the
// `sessions` path (removing the entry while a load is still in flight),
// the pending task will fail and both concurrent loaders will lose
// their handle.
#[gpui::test]
async fn test_close_during_load_preserves_other_concurrent_loader(
async fn test_dropping_one_loader_during_load_preserves_other_concurrent_loader(
cx: &mut gpui::TestAppContext,
) {
let (
@@ -4340,34 +4332,29 @@ mod tests {
"load_session RPC should only be dispatched once for concurrent loads"
);
// Close one of the two handles while the shared load is still parked.
// Because a second loader still holds a pending ref, this should be a
// no-op on the wire.
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.expect("close during load failed");
drop(first_load);
cx.run_until_parked();
assert_eq!(
close_count.load(Ordering::SeqCst),
0,
"close_session RPC must not be dispatched while another load handle remains"
"close_session RPC must not be dispatched while another loader remains"
);
// Release the gate so the load RPC can finally respond.
gate_tx.send(()).await.expect("gate send failed");
drop(gate_tx);
let first_thread = first_load.await.expect("first load should still succeed");
let second_thread = second_load.await.expect("second load should still succeed");
cx.run_until_parked();
assert_eq!(
first_thread.entity_id(),
second_thread.entity_id(),
"concurrent loads should share one AcpThread"
close_count.load(Ordering::SeqCst),
0,
"close_session RPC must not be dispatched while a thread handle is alive"
);
assert!(
connection.sessions.borrow().contains_key(&session_id),
"session must remain tracked while a load handle is still outstanding"
"session must remain tracked while a thread handle is alive"
);
assert!(
!connection
@@ -4377,11 +4364,8 @@ mod tests {
"pending_sessions entry should be cleared once the load resolves"
);
// Final close drops ref_count to 0 and dispatches the ACP close RPC.
cx.update(|cx| connection.clone().close_session(&session_id, cx))
.await
.expect("final close failed");
cx.run_until_parked();
drop(second_thread);
release_dropped_entities(cx);
assert_eq!(
close_count.load(Ordering::SeqCst),
1,
@@ -4389,9 +4373,14 @@ mod tests {
);
assert!(
!connection.sessions.borrow().contains_key(&session_id),
"session should be removed after final close"
"session should be removed after the last handle is released"
);
}
fn release_dropped_entities(cx: &mut gpui::TestAppContext) {
cx.update(|_| ());
cx.run_until_parked();
}
}
fn mcp_servers_for_project(project: &Entity<Project>, cx: &App) -> Vec<acp::McpServer> {
@@ -5124,7 +5113,7 @@ fn handle_wait_for_terminal_exit(
.run_until_cancelled(async {
let exit_status = thread
.update(cx, |thread, cx| {
anyhow::Ok(thread.terminal(args.terminal_id)?.read(cx).wait_for_exit())
thread.terminal(args.terminal_id)?.read(cx).wait_for_exit()
})
.flatten_acp()?
.await;
+1
View File
@@ -361,6 +361,7 @@ mod tests {
version: SharedString::from("1.0.0"),
repository: None,
website: None,
license_url: None,
icon_path: None,
},
package: id,
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/agent_settings.rs"
doctest = false
[dependencies]
anyhow.workspace = true
+260 -7
View File
@@ -10,7 +10,7 @@ use anyhow::Context as _;
use collections::{HashSet, IndexMap};
use fs::Fs;
use futures::channel::oneshot;
use gpui::{App, Pixels, SharedString};
use gpui::{App, Pixels, SharedString, px};
use language_model::LanguageModel;
use project::DisableAiSettings;
use schemars::JsonSchema;
@@ -31,6 +31,12 @@ pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
include_str!("prompts/summarize_thread_detailed_prompt.txt");
pub const COMPACTION_PROMPT: &str = include_str!("prompts/compaction_prompt.txt");
/// Bounds on the width of the threads list. They constrain the configured
/// default as well as the width the user drags to, so that no width the sidebar
/// can hold is able to crowd out the editor or collapse the list.
pub const THREADS_LIST_MIN_WIDTH: Pixels = px(200.0);
pub const THREADS_LIST_MAX_WIDTH: Pixels = px(800.0);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PanelLayout {
pub(crate) agent_dock: Option<DockPosition>,
@@ -207,7 +213,7 @@ pub struct AgentSettings {
pub button: bool,
pub dock: DockPosition,
pub flexible: bool,
pub sidebar_side: SidebarDockPosition,
pub threads_sidebar: ThreadsSidebarSettings,
pub default_width: Pixels,
pub default_height: Pixels,
pub max_content_width: Option<Pixels>,
@@ -227,6 +233,7 @@ pub struct AgentSettings {
pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
pub play_sound_when_agent_done: PlaySoundWhenAgentDone,
pub prevent_idle_sleep: bool,
pub single_file_review: bool,
pub model_parameters: Vec<LanguageModelParameters>,
pub auto_compact: AutoCompactSettings,
@@ -244,6 +251,13 @@ pub struct AgentSettings {
pub sandbox_permissions: SandboxPermissions,
}
#[derive(Clone, Copy, Debug)]
pub struct ThreadsSidebarSettings {
pub auto_open: bool,
pub position: SidebarDockPosition,
pub default_width: Pixels,
}
impl AgentSettings {
pub fn enabled(&self, cx: &App) -> bool {
self.enabled && !DisableAiSettings::get_global(cx).disable_ai
@@ -268,7 +282,7 @@ impl AgentSettings {
}
pub fn sidebar_side(&self) -> SidebarSide {
match self.sidebar_side {
match self.threads_sidebar.position {
SidebarDockPosition::Left => SidebarSide::Left,
SidebarDockPosition::Right => SidebarSide::Right,
}
@@ -754,11 +768,22 @@ pub fn normalize_path(raw: &str) -> String {
impl Settings for AgentSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let agent = content.agent.clone().unwrap();
let threads_sidebar = agent.threads_sidebar.unwrap();
Self {
enabled: agent.enabled.unwrap(),
button: agent.button.unwrap(),
dock: agent.dock.unwrap(),
sidebar_side: agent.sidebar_side.unwrap(),
threads_sidebar: ThreadsSidebarSettings {
auto_open: threads_sidebar.auto_open.unwrap(),
position: threads_sidebar.position.unwrap(),
// Clamped once here so that every reader gets a width the sidebar can
// actually hold, rather than each call site having to remember to.
default_width: threads_sidebar
.default_width
.unwrap()
.into_gpui()
.clamp(THREADS_LIST_MIN_WIDTH, THREADS_LIST_MAX_WIDTH),
},
default_width: agent.default_width.unwrap().into_gpui(),
default_height: agent.default_height.unwrap().into_gpui(),
max_content_width: if agent.limit_content_width.unwrap() {
@@ -792,6 +817,7 @@ impl Settings for AgentSettings {
notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(),
prevent_idle_sleep: agent.prevent_idle_sleep.unwrap(),
single_file_review: agent.single_file_review.unwrap(),
model_parameters: agent.model_parameters,
auto_compact: {
@@ -989,10 +1015,9 @@ fn compile_regex_rules(
#[cfg(test)]
mod tests {
use super::*;
use gpui::{TestAppContext, UpdateGlobal};
use gpui::{TestAppContext, UpdateGlobal, px};
use serde_json::json;
use settings::ToolPermissionMode;
use settings::ToolPermissionsContent;
use settings::{ToolPermissionMode, ToolPermissionsContent};
use std::path::PathBuf;
#[test]
@@ -1062,6 +1087,234 @@ mod tests {
assert!(result.is_none());
}
#[gpui::test]
fn test_prevent_idle_sleep_defaults_to_true_and_follows_user_settings(cx: &mut gpui::App) {
let store = SettingsStore::test(cx);
cx.set_global(store);
project::DisableAiSettings::register(cx);
AgentSettings::register(cx);
assert!(AgentSettings::get_global(cx).prevent_idle_sleep);
for (content, expected) in [
(r#"{"agent": {"prevent_idle_sleep": false}}"#, false),
(r#"{"agent": {"prevent_idle_sleep": true}}"#, true),
(r#"{"agent": {"prevent_idle_sleep": null}}"#, true),
(r#"{"agent": {}}"#, true),
] {
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(content, cx)
.expect("user settings should load");
});
assert_eq!(
AgentSettings::get_global(cx).prevent_idle_sleep,
expected,
"{content}"
);
}
}
#[gpui::test]
fn test_threads_sidebar_settings(cx: &mut gpui::App) {
let store = SettingsStore::test(cx);
cx.set_global(store);
project::DisableAiSettings::register(cx);
AgentSettings::register(cx);
for (content, expected_position, expected_width) in [
(r#"{}"#, SidebarDockPosition::Left, px(300.)),
(
r#"{ "agent": { "threads_sidebar": { "position": "right", "default_width": 360 } } }"#,
SidebarDockPosition::Right,
px(360.),
),
(
r#"{ "agent": { "threads_sidebar": { "position": "right" } } }"#,
SidebarDockPosition::Right,
px(300.),
),
(
r#"{ "agent": { "threads_sidebar": { "default_width": 360 } } }"#,
SidebarDockPosition::Left,
px(360.),
),
(
r#"{ "agent": { "threads_sidebar": { "position": null, "default_width": null } } }"#,
SidebarDockPosition::Left,
px(300.),
),
(
r#"{ "agent": { "threads_sidebar": null } }"#,
SidebarDockPosition::Left,
px(300.),
),
(
r#"{ "agent": { "threads_sidebar": { "default_width": 5 } } }"#,
SidebarDockPosition::Left,
THREADS_LIST_MIN_WIDTH,
),
(
r#"{ "agent": { "threads_sidebar": { "default_width": 5000 } } }"#,
SidebarDockPosition::Left,
THREADS_LIST_MAX_WIDTH,
),
] {
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(content, cx)
.expect("user settings load");
});
let settings = AgentSettings::get_global(cx);
assert_eq!(
settings.threads_sidebar.position, expected_position,
"{content}"
);
assert_eq!(
settings.threads_sidebar.default_width, expected_width,
"{content}"
);
assert_eq!(
settings.sidebar_side(),
match expected_position {
SidebarDockPosition::Left => SidebarSide::Left,
SidebarDockPosition::Right => SidebarSide::Right,
},
"{content}"
);
assert_eq!(settings.default_width, px(640.), "{content}");
assert!(settings.threads_sidebar.auto_open, "{content}");
}
for (value, expected) in [("false", false), ("true", true), ("null", true)] {
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(
&format!(r#"{{"agent":{{"threads_sidebar":{{"auto_open":{value}}}}}}}"#),
cx,
)
.expect("user settings load");
});
assert_eq!(
AgentSettings::get_global(cx).threads_sidebar.auto_open,
expected
);
}
}
#[gpui::test]
fn test_threads_sidebar_settings_edit_and_reset_clears_legacy_keys(cx: &mut gpui::App) {
let store = SettingsStore::test(cx);
cx.set_global(store);
project::DisableAiSettings::register(cx);
AgentSettings::register(cx);
#[derive(Clone, Copy)]
enum Field {
Position,
DefaultWidth,
AutoOpen,
}
let cases = [
(Field::Position, "sidebar_side"),
(Field::DefaultWidth, "threads_sidebar_default_width"),
(Field::AutoOpen, "threads_sidebar_auto_open"),
];
let content = r#"
{
// This comment and the unrelated setting should survive edits.
"agent": {
"sidebar_side": "right",
"threads_sidebar_default_width": 420,
"threads_sidebar_auto_open": true,
"threads_sidebar": {
"position": "left",
"default_width": 360,
"auto_open": false
}
},
"unrelated": { "keep": true }
}
"#;
for (field, legacy_key) in cases {
let mut rewrite = |old_text: String, set_value: bool| {
SettingsStore::update_global(cx, |store, _| {
store
.new_text_for_update(old_text, |settings| {
let agent = settings.agent.get_or_insert_default();
match field {
Field::Position => agent.set_threads_sidebar_position(
set_value.then_some(SidebarDockPosition::Right),
),
Field::DefaultWidth => agent.set_threads_sidebar_default_width(
set_value.then_some(500.0.into()),
),
Field::AutoOpen => {
agent.set_threads_sidebar_auto_open(set_value.then_some(true))
}
}
})
.expect("settings update should succeed")
})
};
let assert_legacy_edit = |text: &str| {
let value: serde_json_lenient::Value =
serde_json_lenient::from_str(text).expect("rewritten settings are valid");
let agent = value
.get("agent")
.and_then(serde_json_lenient::Value::as_object)
.expect("agent settings should remain an object");
assert!(
!agent.contains_key(legacy_key),
"{legacy_key} was not removed"
);
for sibling in [
"sidebar_side",
"threads_sidebar_default_width",
"threads_sidebar_auto_open",
] {
if sibling != legacy_key {
assert!(agent.contains_key(sibling), "{sibling} was not preserved");
}
}
assert_eq!(
value
.get("unrelated")
.and_then(serde_json_lenient::Value::as_object)
.and_then(|object| object.get("keep"))
.and_then(serde_json_lenient::Value::as_bool),
Some(true)
);
assert!(text.contains("This comment and the unrelated setting"));
};
let reset_text = rewrite(content.to_string(), false);
assert_legacy_edit(&reset_text);
let set_text = rewrite(content.to_string(), true);
assert_legacy_edit(&set_text);
let reset_after_set_text = rewrite(set_text, false);
assert_legacy_edit(&reset_after_set_text);
let expected_width = match field {
Field::DefaultWidth => px(300.),
Field::Position | Field::AutoOpen => px(360.),
};
let expected_auto_open = matches!(field, Field::AutoOpen);
for rewritten in [reset_text, reset_after_set_text] {
SettingsStore::update_global(cx, |store, cx| {
let result = store.set_user_settings(&rewritten, cx);
assert!(result.parse_error().is_none(), "settings should parse");
});
let settings = AgentSettings::get_global(cx);
assert_eq!(settings.threads_sidebar.position, SidebarDockPosition::Left);
assert_eq!(settings.threads_sidebar.default_width, expected_width);
assert_eq!(settings.threads_sidebar.auto_open, expected_auto_open);
}
}
}
#[gpui::test]
fn test_terminal_init_command_filters_empty_without_trimming(cx: &mut gpui::App) {
let store = SettingsStore::test(cx);
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "agent_skills.rs"
doctest = false
[dependencies]
anyhow.workspace = true
+39 -22
View File
@@ -64,7 +64,7 @@ impl SkillLoadWarning {
actual_len,
max_len,
} => format!(
"Skill description is {actual_len} bytes, exceeding the {max_len}-byte limit. The skill was loaded, but long descriptions may consume more model-context tokens."
"Skill description is {actual_len} characters, exceeding the {max_len}-character limit. The skill was loaded, but long descriptions may consume more model-context tokens."
),
}
}
@@ -330,9 +330,10 @@ fn validate_description_for_loading(
}
let mut warnings = Vec::new();
if description.len() > MAX_SKILL_DESCRIPTION_LEN {
let description_len = description.chars().count();
if description_len > MAX_SKILL_DESCRIPTION_LEN {
warnings.push(SkillLoadWarning::DescriptionTooLong {
actual_len: description.len(),
actual_len: description_len,
max_len: MAX_SKILL_DESCRIPTION_LEN,
});
}
@@ -416,13 +417,9 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> {
/// by [`validate_name`].
pub const MAX_SKILL_NAME_LEN: usize = 64;
/// Maximum recommended length (in bytes) for a skill description. The
/// Maximum recommended length (in Unicode scalar values) for a skill description. The
/// create-skill UI enforces this as a hard limit, while the loader emits a
/// warning and still loads longer descriptions.
///
/// Byte-based rather than char-based because that's what `.len()` returns
/// and what every caller currently measures; the UI also surfaces this
/// limit as a byte count so the editor's counter matches the validator.
pub const MAX_SKILL_DESCRIPTION_LEN: usize = 1024;
/// Convert an arbitrary human-readable string into a valid skill name, or
@@ -532,9 +529,9 @@ pub fn validate_description(description: &str) -> Result<(), &'static str> {
if description.trim().is_empty() {
return Err("Skill description cannot be empty");
}
if description.len() > MAX_SKILL_DESCRIPTION_LEN {
if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN {
return Err(formatcp!(
"Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} bytes"
"Skill description must be at most {MAX_SKILL_DESCRIPTION_LEN} characters"
));
}
Ok(())
@@ -1373,9 +1370,33 @@ Content.
}
}
#[test]
fn test_parse_unicode_description_at_limit_loads_without_warning() {
let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN);
let content = format!(
r#"---
name: test
description: {description}
---
Content.
"#
);
let skill = parse_skill_frontmatter(
Path::new("/skills/test/SKILL.md"),
&content,
SkillSource::Global,
)
.expect("descriptions at the character limit should load without a warning");
assert_eq!(skill.description, description);
assert!(skill.load_warnings.is_empty());
}
#[test]
fn test_parse_description_too_long_loads_with_warning() {
let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
let long_desc = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
let content = format!(
r#"---
name: test
@@ -1406,7 +1427,7 @@ Content.
#[test]
fn test_parse_skill_file_content_rejects_description_too_long() {
let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
let long_desc = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
let content = format!(
r#"---
name: test
@@ -1419,7 +1440,7 @@ Content.
let result = parse_skill_file_content(&content);
assert!(result.is_err());
let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes");
let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} characters");
assert!(result.unwrap_err().to_string().contains(&expected));
}
@@ -2133,16 +2154,12 @@ description: A skill with no body content
}
#[test]
fn validate_description_length_is_measured_in_bytes() {
// "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has
// only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be
// rejected by a byte-based validator (and accepted by a char-based
// one). This regression-tests the byte semantics that strict
// validation and load-time warnings both rely on.
let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1;
let description = "é".repeat(chars);
assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN);
fn validate_description_length_is_measured_in_characters() {
let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN);
assert!(description.len() > MAX_SKILL_DESCRIPTION_LEN);
assert!(validate_description(&description).is_ok());
let description = "中".repeat(MAX_SKILL_DESCRIPTION_LEN + 1);
assert!(validate_description(&description).is_err());
}
+5
View File
@@ -118,6 +118,11 @@ impl AgentDiffPane {
cx.subscribe(&thread, |this, _thread, event, cx| {
this.handle_acp_thread_event(event, cx)
}),
cx.subscribe(&editor, |_, _, event: &EditorEvent, cx| {
if event == &(EditorEvent::SelectionsChanged { local: true }) {
cx.emit(event.clone())
}
}),
],
multibuffer,
editor,
+237 -18
View File
@@ -50,9 +50,9 @@ use crate::{
};
use crate::{
AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, LoadThreadFromClipboard,
NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings,
ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata,
ToggleNewThreadMenu, ToggleOptionsMenu,
NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, RenameSelectedThread,
ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata,
ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu,
conversation_view::{
AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings,
},
@@ -1169,6 +1169,8 @@ pub struct AgentPanel {
retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
terminals: HashMap<TerminalId, AgentTerminal>,
pending_terminal_spawn: Option<TerminalId>,
#[cfg(test)]
test_terminal_spawn_gate: Option<futures::channel::oneshot::Receiver<()>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
_extension_subscription: Option<Subscription>,
@@ -1582,6 +1584,8 @@ impl AgentPanel {
retained_threads: HashMap::default(),
terminals: HashMap::default(),
pending_terminal_spawn: None,
#[cfg(test)]
test_terminal_spawn_gate: None,
new_thread_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
@@ -2073,11 +2077,14 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
// The spawn is async, so the panel keeps its current base view until
// the terminal lands. Marking the spawn as pending stops
// `ensure_thread_initialized` from treating a still-uninitialized panel
// as empty and spawning a second, "initial" terminal on activation.
self.pending_terminal_spawn = Some(terminal_id);
let terminal_working_directory = working_directory.clone();
let init_command = Self::terminal_init_command(run_init_command, cx);
let terminal_task = self.project.update(cx, |project, cx| {
project.create_terminal_shell(working_directory, cx)
});
let terminal_task = self.create_terminal_shell(working_directory, cx);
let workspace = self.workspace.clone();
let workspace_id = self.workspace_id;
let project = self.project.downgrade();
@@ -2128,6 +2135,27 @@ impl AgentPanel {
.detach_and_log_err(cx);
}
fn create_terminal_shell(
&mut self,
working_directory: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<terminal::Terminal>>> {
// A real shell ties the spawn's timing to the host, so a test that needs
// to observe the panel mid-spawn installs a gate and gets a display-only
// terminal when it releases it.
#[cfg(test)]
if let Some(gate) = self.test_terminal_spawn_gate.take() {
return cx.spawn(async move |this, cx| {
gate.await.ok();
this.update(cx, |this, cx| this.build_display_only_terminal(cx))
});
}
self.project.update(cx, |project, cx| {
project.create_terminal_shell(working_directory, cx)
})
}
fn terminal_init_command(run_init_command: bool, cx: &App) -> Option<String> {
run_init_command
.then(|| AgentSettings::get_global(cx).terminal_init_command.clone())
@@ -2452,7 +2480,6 @@ impl AgentPanel {
return;
}
self.pending_terminal_spawn = Some(metadata.terminal_id);
let working_directory = self.terminal_restore_working_directory(&metadata, workspace, cx);
let initial_title = Self::terminal_restore_initial_title(&metadata);
self.spawn_terminal(
@@ -6519,6 +6546,12 @@ impl Render for AgentPanel {
cx.stop_propagation();
this.new_terminal(None, AgentThreadSource::AgentPanel, window, cx);
}))
.on_action(cx.listener(|this, _: &RenameSelectedThread, window, cx| {
let Some(terminal_id) = this.active_terminal_id() else {
return;
};
this.edit_terminal_title(terminal_id, window, cx);
}))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
this.open_configuration(window, cx);
}))
@@ -6791,6 +6824,21 @@ impl AgentPanel {
)
}
#[cfg(any(test, feature = "test-support"))]
fn build_display_only_terminal(&self, cx: &mut Context<Self>) -> Entity<terminal::Terminal> {
let settings = TerminalSettings::get_global(cx).clone();
let path_style = self.project.read(cx).path_style(cx);
let builder = terminal::TerminalBuilder::new_display_only(
settings.cursor_shape,
settings.alternate_scroll,
settings.max_scroll_history_lines,
cx.entity_id().as_u64(),
cx.background_executor(),
path_style,
);
cx.new(|cx| builder.subscribe(cx))
}
#[cfg(any(test, feature = "test-support"))]
fn insert_display_only_terminal(
&mut self,
@@ -6807,17 +6855,7 @@ impl AgentPanel {
cx: &mut Context<Self>,
) -> Result<()> {
let init_command = Self::terminal_init_command(run_init_command, cx);
let settings = TerminalSettings::get_global(cx).clone();
let path_style = self.project.read(cx).path_style(cx);
let builder = terminal::TerminalBuilder::new_display_only(
settings.cursor_shape,
settings.alternate_scroll,
settings.max_scroll_history_lines,
cx.entity_id().as_u64(),
cx.background_executor(),
path_style,
);
let terminal = cx.new(|cx| builder.subscribe(cx));
let terminal = self.build_display_only_terminal(cx);
let terminal_for_init_command = terminal.clone();
let terminal_view = cx.new(|cx| {
let mut view = TerminalView::new(
@@ -7668,6 +7706,62 @@ mod tests {
});
}
#[gpui::test]
async fn test_new_terminal_prevents_initial_terminal_creation(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
let (terminal_landed, spawn_gate) = futures::channel::oneshot::channel::<()>();
// `create_new_terminal` in the sidebar calls `new_terminal` and then
// focuses the panel, which activates it while the spawn is still in
// flight. The gate keeps the spawn in flight for as long as the test
// wants, instead of racing a real shell.
panel.update_in(&mut cx, |panel, window, cx| {
panel.test_terminal_spawn_gate = Some(spawn_gate);
panel.new_terminal(None, AgentThreadSource::AgentPanel, window, cx);
assert!(
panel.pending_terminal_spawn.is_some(),
"a new terminal spawn should be marked pending until it lands"
);
panel.set_active(true, window, cx);
});
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
assert!(
panel.terminals(cx).is_empty(),
"no terminal should land while the spawn is held"
);
assert!(
panel.pending_terminal_spawn.is_some(),
"the in-flight spawn should still be pending after activation"
);
});
terminal_landed
.send(())
.expect("the panel should still be waiting on the terminal spawn");
cx.run_until_parked();
panel.read_with(&cx, |panel, cx| {
let terminals = panel.terminals(cx);
assert_eq!(
terminals.len(),
1,
"activation while a new terminal is spawning should not create a second terminal"
);
assert_eq!(
panel.active_terminal_id(),
terminals.first().map(|terminal| terminal.id),
"the terminal that was spawned should be the active one"
);
assert!(
panel.pending_terminal_spawn.is_none(),
"the pending marker should be cleared once the terminal lands"
);
});
}
#[gpui::test]
async fn test_restored_terminal_runs_init_command_once(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
@@ -9806,6 +9900,131 @@ mod tests {
});
}
#[gpui::test]
async fn test_rename_selected_thread_action_opens_and_focuses_terminal_title_editor(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_visible_panel(cx).await;
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Dev Server", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
cx.dispatch_action(RenameSelectedThread);
cx.run_until_parked();
panel.update_in(&mut cx, |panel, window, cx| {
assert_eq!(panel.active_terminal_id(), Some(terminal_id));
assert!(panel.is_title_editor_focused(window, cx));
});
}
#[gpui::test]
async fn test_rename_selected_thread_action_does_not_edit_terminal_from_agent_thread(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_visible_panel(cx).await;
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Dev Server", true, window, cx)
})
.expect("test terminal should be inserted");
panel.update_in(&mut cx, |panel, window, cx| {
panel.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
});
cx.run_until_parked();
cx.dispatch_action(RenameSelectedThread);
cx.run_until_parked();
panel.read_with(&cx, |panel, _cx| {
assert!(matches!(
panel.visible_surface(),
VisibleSurface::AgentThread(_)
));
assert!(
panel
.terminals
.get(&terminal_id)
.is_some_and(|terminal| terminal.title_editor.is_none())
);
});
}
#[gpui::test]
async fn test_rename_selected_thread_action_reuses_terminal_title_editor(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_visible_panel(cx).await;
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Dev Server", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
cx.dispatch_action(RenameSelectedThread);
cx.run_until_parked();
let first_editor_id = panel.read_with(&cx, |panel, _cx| {
panel
.terminals
.get(&terminal_id)
.and_then(|terminal| terminal.title_editor.as_ref())
.expect("terminal title editor should be active")
.entity_id()
});
cx.dispatch_action(RenameSelectedThread);
cx.run_until_parked();
panel.read_with(&cx, |panel, _cx| {
let editor = panel
.terminals
.get(&terminal_id)
.and_then(|terminal| terminal.title_editor.as_ref())
.expect("terminal title editor should remain active");
assert_eq!(editor.entity_id(), first_editor_id);
});
}
#[gpui::test]
async fn test_rename_selected_thread_action_edits_only_active_terminal(
cx: &mut TestAppContext,
) {
let (panel, mut cx) = setup_visible_panel(cx).await;
let first_terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Build", true, window, cx)
})
.expect("first test terminal should be inserted");
let second_terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Server", true, window, cx)
})
.expect("second test terminal should be inserted");
cx.run_until_parked();
cx.dispatch_action(RenameSelectedThread);
cx.run_until_parked();
panel.read_with(&cx, |panel, _cx| {
assert_eq!(panel.active_terminal_id(), Some(second_terminal_id));
assert!(
panel
.terminals
.get(&first_terminal_id)
.is_some_and(|terminal| terminal.title_editor.is_none())
);
assert!(
panel
.terminals
.get(&second_terminal_id)
.is_some_and(|terminal| terminal.title_editor.is_some())
);
});
}
#[gpui::test]
async fn test_restored_terminal_uses_metadata_title_until_shell_title_arrives(
cx: &mut TestAppContext,
+23 -1
View File
@@ -444,6 +444,27 @@ impl AgentRegistryPage {
})
});
let license_button = agent.license_url().map(|license_url| {
let license_url = license_url.clone();
let license_url_for_click = license_url.clone();
IconButton::new(
SharedString::from(format!("agent-license-{}", agent.id())),
IconName::FileTextOutlined,
)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"View Agent License or Terms of Service",
None,
license_url.clone(),
cx,
)
})
.on_click(move |_, _, cx| {
cx.open_url(&license_url_for_click);
})
});
AgentRegistryCard::new()
.child(
h_flex()
@@ -483,7 +504,8 @@ impl AgentRegistryPage {
.truncate(),
)
.when_some(repository_button, |this, button| this.child(button))
.when_some(website_button, |this, button| this.child(button)),
.when_some(website_button, |this, button| this.child(button))
.when_some(license_button, |this, button| this.child(button)),
),
)
}
+36 -7
View File
@@ -865,15 +865,12 @@ fn update_command_palette_filter(cx: &mut App) {
filter.show_namespace("multi_workspace");
}
// Hide `agent: manage skills` — skills are surfaced through the
// settings UI now. Applied after the disable-ai / agent-enabled
// branches so it overrides the `show_namespace("assistant")` call
// above without affecting the rest of that namespace's actions.
// Skills are surfaced through the settings UI now, so this command
// should never appear in the palette.
filter.hide_action_types(&manage_skills_action);
if !disable_ai {
filter.hide_action_types(&manage_skills_action);
filter.show_action_types(skill_creator_actions.iter());
} else {
filter.show_action_types(manage_skills_action.iter());
filter.hide_action_types(&skill_creator_actions);
}
});
@@ -995,6 +992,7 @@ mod tests {
profiles: Default::default(),
notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
play_sound_when_agent_done: PlaySoundWhenAgentDone::Never,
prevent_idle_sleep: true,
single_file_review: false,
model_parameters: vec![],
auto_compact: agent_settings::AutoCompactSettings {
@@ -1012,7 +1010,11 @@ mod tests {
sandbox_permissions: Default::default(),
show_turn_stats: false,
show_merge_conflict_indicator: true,
sidebar_side: Default::default(),
threads_sidebar: agent_settings::ThreadsSidebarSettings {
auto_open: true,
default_width: px(300.),
position: settings::SidebarDockPosition::Left,
},
thinking_display: Default::default(),
};
@@ -1051,6 +1053,10 @@ mod tests {
!filter.is_hidden(&zed_actions::assistant::OpenProjectAgentsMdRules),
"OpenProjectAgentsMdRules should be visible by default"
);
assert!(
filter.is_hidden(&zed_actions::assistant::ManageSkills),
"ManageSkills should be hidden even when AI is enabled"
);
});
// Disable agent
@@ -1128,6 +1134,29 @@ mod tests {
"EditPrediction should be hidden when provider is None"
);
});
// Disable AI entirely
cx.update(|cx| {
AgentSettings::override_global(agent_settings.clone(), cx);
DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
update_command_palette_filter(cx);
});
cx.update(|cx| {
let filter = CommandPaletteFilter::try_global(cx).unwrap();
assert!(
filter.is_hidden(&zed_actions::assistant::ManageSkills),
"ManageSkills should be hidden when AI is disabled"
);
assert!(
filter.is_hidden(&zed_actions::assistant::OpenSkillCreator),
"OpenSkillCreator should be hidden when AI is disabled"
);
assert!(
filter.is_hidden(&NewThread),
"NewThread should be hidden when AI is disabled"
);
});
}
async fn setup_backfill_test(cx: &mut TestAppContext) -> Arc<dyn Fs> {
+8 -2
View File
@@ -497,6 +497,7 @@ impl CodegenAlternative {
.context("generating content prompt")?;
let temperature = AgentSettings::temperature_for_model(model, cx);
let session_id = self.session_id.to_string();
let tool_choice = model
.supports_tool_choice(LanguageModelToolChoice::Any)
@@ -544,7 +545,8 @@ impl CodegenAlternative {
];
LanguageModelRequest {
thread_id: None,
thread_id: Some(session_id),
prompt_cache_key: None,
prompt_id: None,
intent: Some(CompletionIntent::InlineAssist),
tools,
@@ -556,6 +558,7 @@ impl CodegenAlternative {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
}
}))
}
@@ -609,6 +612,7 @@ impl CodegenAlternative {
.context("generating content prompt")?;
let temperature = AgentSettings::temperature_for_model(model, cx);
let session_id = self.session_id.to_string();
Ok(cx.spawn(async move |_cx| {
let mut request_message = LanguageModelRequestMessage {
@@ -625,7 +629,8 @@ impl CodegenAlternative {
request_message.content.push(prompt.into());
LanguageModelRequest {
thread_id: None,
thread_id: Some(session_id),
prompt_cache_key: None,
prompt_id: None,
intent: Some(CompletionIntent::InlineAssist),
tools: Vec::new(),
@@ -637,6 +642,7 @@ impl CodegenAlternative {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
}
}))
}
File diff suppressed because it is too large. Load diff
@@ -4,7 +4,7 @@ use collections::{HashMap, HashSet};
use component::{Component, ComponentScope, example_group_with_title, single_example};
use editor::Editor;
use futures::channel::oneshot;
use gpui::{AnyElement, App, Div, Empty, Entity, Hsla, SharedString, Window, div};
use gpui::{AnyElement, App, Div, Empty, Entity, Focusable, Hsla, SharedString, Window, div};
use std::collections::BTreeMap;
use std::rc::Rc;
use ui::{
@@ -363,7 +363,352 @@ impl ElicitationFormSubmission {
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use gpui::{KeyUpEvent, Keystroke, PlatformInput, TestAppContext, VisualTestContext};
use std::cell::RefCell;
struct TestElicitationView {
elicitation: Elicitation,
form_state: ElicitationFormState,
events: Rc<RefCell<Vec<String>>>,
}
impl TestElicitationView {
fn new(schema: acp::ElicitationSchema, window: &mut Window, cx: &mut App) -> Self {
Self {
form_state: ElicitationFormState::new(&schema, window, cx),
elicitation: Elicitation {
id: ElicitationEntryId("keyboard-test".into()),
request: acp::CreateElicitationRequest::new(
acp::ElicitationFormMode::new(preview_request_scope(0), schema),
"Choose an answer.",
),
status: pending_status(),
},
events: Rc::default(),
}
}
fn editor(&self, field_name: &str) -> Entity<Editor> {
match self.form_state.fields.get(field_name) {
Some(ElicitationFieldState::Text(editor)) => editor.clone(),
_ => panic!("expected a text field named {field_name}"),
}
}
}
impl Render for TestElicitationView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let response_handler = |response: &'static str| -> RespondHandler {
let events = self.events.clone();
let expected_id = self.elicitation.id.clone();
Rc::new(move |id, _, _| {
assert_eq!(id, expected_id);
events.borrow_mut().push(response.into());
})
};
let handlers = ElicitationCardHandlers {
on_submit: response_handler("submit"),
on_decline: response_handler("decline"),
on_cancel: response_handler("cancel"),
on_dismiss_url: response_handler("dismiss"),
on_open_url: {
let events = self.events.clone();
let expected_id = self.elicitation.id.clone();
Rc::new(move |id, url, _, _| {
assert_eq!(id, expected_id);
events.borrow_mut().push(format!("open: {url}"));
})
},
on_boolean_change: {
let events = self.events.clone();
Rc::new(move |_, name, value, _| {
events.borrow_mut().push(format!("{name}: {value}"));
})
},
on_single_select_change: {
let events = self.events.clone();
Rc::new(move |_, name, value, _| {
events.borrow_mut().push(format!("{name}: {value}"));
})
},
on_multi_select_change: {
let events = self.events.clone();
Rc::new(move |_, name, value, selected, _| {
events
.borrow_mut()
.push(format!("{name}: {value} = {selected}"));
})
},
};
div()
.key_context("AcpThread")
.on_action(|_: &crate::CycleModeSelector, _, _| {
panic!("form navigation must not cycle the agent mode");
})
.on_action(|_: &zed_actions::agent::Chat, _, _| {
panic!("form submission must not send a chat message");
})
.child(
ElicitationCard::new(
0,
&self.elicitation,
"Claude Code".into(),
Some(&self.form_state),
handlers,
)
.render(cx),
)
}
}
fn init_keyboard_test(cx: &mut TestAppContext) {
crate::conversation_view::tests::init_test(cx);
cx.update(|cx| {
let bindings = settings::KeymapFile::load_asset_allow_partial_failure(
"keymaps/default-linux.json",
cx,
)
.expect("default keymap should load");
cx.bind_keys(bindings);
});
}
fn press_keys(cx: &mut VisualTestContext, keys: &str) {
for key in keys.split_whitespace() {
cx.simulate_keystrokes(key);
cx.update(|window, cx| {
// Click handlers activate buttons on key release, not key press.
window.dispatch_event(
PlatformInput::KeyUp(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("valid test keystroke"),
}),
cx,
);
});
cx.run_until_parked();
}
}
#[gpui::test]
fn form_text_fields_submit_on_enter(cx: &mut TestAppContext) {
init_keyboard_test(cx);
let (view, cx) = cx.add_window_view(|window, cx| {
TestElicitationView::new(
acp::ElicitationSchema::new()
.string("other", true)
.property("count", acp::IntegerPropertySchema::new(), true)
.property("amount", acp::NumberPropertySchema::new(), true),
window,
cx,
)
});
let events = view.read_with(cx, |view, _| view.events.clone());
for (field_name, value) in [
("other", " My answer "),
("count", "2"),
("amount", "1.5"),
] {
let editor = view.read_with(cx, |view, _| view.editor(field_name));
cx.update(|window, cx| window.focus(&editor.focus_handle(cx), cx));
cx.simulate_input(value);
press_keys(cx, "enter");
assert_eq!(editor.read_with(cx, |editor, cx| editor.text(cx)), value);
}
assert_eq!(*events.borrow(), ["submit", "submit", "submit"]);
view.update(cx, |view, cx| {
assert!(view.form_state.begin_submission(cx).is_some());
cx.notify();
});
press_keys(cx, "enter");
assert_eq!(events.borrow().len(), 3);
}
#[gpui::test]
fn form_tab_navigation_reaches_fields_and_actions(cx: &mut TestAppContext) {
init_keyboard_test(cx);
let (view, cx) = cx.add_window_view(|window, cx| {
TestElicitationView::new(
acp::ElicitationSchema::new()
.string("first", false)
.string("other", false),
window,
cx,
)
});
let (first, other, events) = view.read_with(cx, |view, _| {
(
view.editor("first"),
view.editor("other"),
view.events.clone(),
)
});
cx.update(|window, cx| window.focus(&first.focus_handle(cx), cx));
press_keys(cx, "tab");
cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window)));
press_keys(cx, "shift-tab");
cx.update(|window, cx| assert!(first.focus_handle(cx).is_focused(window)));
press_keys(cx, "tab tab");
let submit_focus =
cx.update(|window, cx| window.focused(cx).expect("Submit should be focused"));
press_keys(cx, "shift-tab");
cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window)));
press_keys(cx, "tab");
cx.update(|window, _| assert!(submit_focus.is_focused(window)));
press_keys(cx, "enter tab enter tab space");
assert_eq!(*events.borrow(), ["submit", "decline", "cancel"]);
press_keys(cx, "shift-tab space");
assert_eq!(*events.borrow(), ["submit", "decline", "cancel", "decline"]);
let decline_focus =
cx.update(|window, cx| window.focused(cx).expect("Decline should be focused"));
cx.update(|window, cx| window.focus(&submit_focus, cx));
view.update(cx, |view, cx| {
assert!(view.form_state.begin_submission(cx).is_some());
cx.notify();
});
press_keys(cx, "tab");
cx.update(|window, _| assert!(decline_focus.is_focused(window)));
press_keys(cx, "shift-tab");
cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window)));
press_keys(cx, "tab");
cx.update(|window, _| assert!(decline_focus.is_focused(window)));
press_keys(cx, "shift-tab");
cx.update(|window, cx| assert!(other.focus_handle(cx).is_focused(window)));
view.update(cx, |view, cx| {
view.form_state.set_errors(HashMap::default());
cx.notify();
});
press_keys(cx, "tab");
cx.update(|window, _| assert!(submit_focus.is_focused(window)));
}
#[gpui::test]
fn form_choices_are_keyboard_accessible(cx: &mut TestAppContext) {
init_keyboard_test(cx);
let (view, cx) = cx.add_window_view(|window, cx| {
TestElicitationView::new(
acp::ElicitationSchema::new()
.string("answer", false)
.property("boolean", acp::BooleanPropertySchema::new(), false)
.property(
"choice",
acp::StringPropertySchema::new()
.enum_values(vec!["first".into(), "second".into()]),
false,
)
.property(
"multiple",
acp::MultiSelectPropertySchema::new(vec!["first".into(), "second".into()]),
false,
),
window,
cx,
)
});
let (editor, events) =
view.read_with(cx, |view, _| (view.editor("answer"), view.events.clone()));
cx.update(|window, cx| window.focus(&editor.focus_handle(cx), cx));
press_keys(
cx,
"tab space tab enter tab space tab space tab enter tab enter",
);
assert_eq!(
*events.borrow(),
[
"boolean: true",
"choice: first",
"choice: second",
"multiple: first = true",
"multiple: second = true",
"submit",
]
);
}
#[gpui::test]
fn tab_navigation_between_form_and_url_cards(cx: &mut TestAppContext) {
struct Cards {
form: Entity<TestElicitationView>,
url: Entity<TestElicitationView>,
}
impl Render for Cards {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex().child(self.form.clone()).child(self.url.clone())
}
}
init_keyboard_test(cx);
let (cards, cx) = cx.add_window_view(|window, cx| Cards {
form: cx.new(|cx| {
TestElicitationView::new(
acp::ElicitationSchema::new().string("other", false),
window,
cx,
)
}),
url: cx.new(|cx| {
let mut view = TestElicitationView::new(acp::ElicitationSchema::new(), window, cx);
view.elicitation.id = ElicitationEntryId("url-test".into());
view.elicitation.request = acp::CreateElicitationRequest::new(
acp::ElicitationUrlMode::new(
preview_request_scope(1),
acp::ElicitationId::new("url-test"),
"https://example.com/authorize",
),
"Authorize access.",
);
view
}),
});
let (form, url) = cards.read_with(cx, |cards, _| (cards.form.clone(), cards.url.clone()));
let (editor, form_events) =
form.read_with(cx, |form, _| (form.editor("other"), form.events.clone()));
let url_events = url.read_with(cx, |url, _| url.events.clone());
cx.update(|window, cx| window.focus(&editor.focus_handle(cx), cx));
press_keys(cx, "tab tab tab enter");
assert_eq!(*form_events.borrow(), ["cancel"]);
press_keys(cx, "tab enter");
assert_eq!(
*url_events.borrow(),
["open: https://example.com/authorize", "submit"]
);
press_keys(cx, "shift-tab enter");
assert_eq!(*form_events.borrow(), ["cancel", "cancel"]);
press_keys(cx, "shift-tab shift-tab enter");
assert_eq!(*form_events.borrow(), ["cancel", "cancel", "submit"]);
press_keys(cx, "shift-tab");
cx.update(|window, cx| assert!(editor.focus_handle(cx).is_focused(window)));
url.update(cx, |url, cx| {
url.elicitation.status = ElicitationStatus::Accepted;
cx.notify();
});
press_keys(cx, "tab tab tab tab enter tab enter");
assert_eq!(
*url_events.borrow(),
[
"open: https://example.com/authorize",
"submit",
"open: https://example.com/authorize",
"dismiss",
]
);
assert_eq!(*form_events.borrow(), ["cancel", "cancel", "submit"]);
}
#[test]
fn string_validation_rejects_email_format_mismatch() {
@@ -1489,6 +1834,10 @@ impl<'a> ElicitationCard<'a> {
};
v_flex()
.key_context("Elicitation")
.tab_group()
.on_action(|_: &menu::SelectNext, window, cx| window.focus_next(cx))
.on_action(|_: &menu::SelectPrevious, window, cx| window.focus_prev(cx))
.mx_5()
.my_1p5()
.rounded_md()
@@ -1576,6 +1925,7 @@ impl<'a> ElicitationCard<'a> {
let label = property_title(field_name, property);
let description = property_description(property);
let border_color = cx.theme().colors().border.opacity(0.8);
let focused_border_color = cx.theme().colors().border_focused;
let field_border_color = if error.is_some() {
Color::Error.color(cx)
} else {
@@ -1606,10 +1956,12 @@ impl<'a> ElicitationCard<'a> {
.child(
h_flex()
.id(row_id)
.tab_index(0)
.w_full()
.items_start()
.gap_1()
.cursor_pointer()
.focus_visible(|this| this.bg(cx.theme().colors().element_hover))
.on_click(move |_, _window, cx| {
on_boolean_change(
elicitation_id.clone(),
@@ -1655,16 +2007,29 @@ impl<'a> ElicitationCard<'a> {
)
})
.child(match field {
ElicitationFieldState::Text(editor) => div()
.rounded_sm()
.border_1()
.border_color(field_border_color)
.bg(editor_background)
.px_1()
.py_0p5()
.text_xs()
.child(editor.clone().into_any_element())
.into_any_element(),
ElicitationFieldState::Text(editor) => {
let on_submit = self.handlers.on_submit.clone();
let elicitation_id = self.elicitation.id.clone();
let is_submitting = self.form_state.is_some_and(|state| state.is_submitting);
div()
.track_focus(&editor.focus_handle(cx).tab_stop(true))
.on_action(move |_: &menu::Confirm, window, cx| {
if !is_submitting {
on_submit(elicitation_id.clone(), window, cx);
}
})
.rounded_sm()
.border_1()
.border_color(field_border_color)
.focus_visible(|this| this.border_color(focused_border_color))
.bg(editor_background)
.px_1()
.py_0p5()
.text_xs()
.child(editor.clone().into_any_element())
.into_any_element()
}
ElicitationFieldState::Boolean(_) => Empty.into_any_element(),
ElicitationFieldState::SingleSelect { value } => {
let options = match property {
@@ -1714,6 +2079,7 @@ impl<'a> ElicitationCard<'a> {
"elicitation-multi-option-{}-{field_name}-{}",
self.entry_ix, option.value
)))
.tab_index(0)
.w_full()
.min_h(rems_from_px(28_f32))
.items_start()
@@ -1725,6 +2091,7 @@ impl<'a> ElicitationCard<'a> {
.px_2()
.py_1()
.hover(move |this| this.bg(hover_background).cursor_pointer())
.focus_visible(|this| this.border_color(focused_border_color))
.on_click(move |_, _window, cx| {
on_multi_select_change(
elicitation_id.clone(),
@@ -1781,6 +2148,7 @@ impl<'a> ElicitationCard<'a> {
h_flex()
.id(option_id)
.tab_index(0)
.w_full()
.min_h(rems_from_px(28_f32))
.items_start()
@@ -1792,6 +2160,7 @@ impl<'a> ElicitationCard<'a> {
.px_2()
.py_1()
.hover(move |this| this.bg(hover_background).cursor_pointer())
.focus_visible(|this| this.border_color(cx.theme().colors().border_focused))
.on_click(move |_, _window, cx| {
on_single_select_change(
elicitation_id.clone(),
@@ -1823,7 +2192,7 @@ impl<'a> ElicitationCard<'a> {
.min_w_0()
.flex_1()
.gap_0p5()
.child(Label::new(option.label).size(LabelSize::Small).truncate())
.child(Label::new(option.label).size(LabelSize::Small))
.when_some(option.description, |this, description| {
this.child(
Label::new(description)
@@ -1976,6 +2345,7 @@ impl<'a> ElicitationCard<'a> {
.border_color(border_color)
.child(
Button::new(("elicitation-accept", self.entry_ix), accept_label)
.tab_index(0_isize)
.start_icon(
Icon::new(accept_icon)
.size(IconSize::XSmall)
@@ -1997,6 +2367,7 @@ impl<'a> ElicitationCard<'a> {
.when(!is_accepted_url, |this| {
this.child(
Button::new(("elicitation-decline", self.entry_ix), "Decline")
.tab_index(0_isize)
.start_icon(
Icon::new(IconName::Close)
.size(IconSize::XSmall)
@@ -2009,6 +2380,7 @@ impl<'a> ElicitationCard<'a> {
)
.child(
Button::new(("elicitation-cancel", self.entry_ix), "Cancel")
.tab_index(0_isize)
.label_size(LabelSize::Small)
.on_click(move |_, window, cx| {
on_cancel(cancel_id.clone(), window, cx);
@@ -2018,6 +2390,7 @@ impl<'a> ElicitationCard<'a> {
.when(is_accepted_url, |this| {
this.child(
Button::new(("elicitation-dismiss-url", self.entry_ix), "Cancel")
.tab_index(0_isize)
.label_size(LabelSize::Small)
.on_click(move |_, window, cx| {
on_dismiss_url(dismiss_id.clone(), window, cx);
@@ -3,8 +3,7 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::{
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ContentBlock,
ToolCallContent,
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ToolCallContent,
};
use collections::HashMap;
use editor::{
@@ -892,18 +891,14 @@ fn collect_markdowns(
for (chunk_ix, chunk) in message.chunks.iter().enumerate() {
match chunk {
AssistantMessageChunk::Message { block, .. } => {
if let Some(md) = block.markdown() {
out.push(md.clone());
}
out.extend(block.markdowns().cloned());
}
AssistantMessageChunk::Thought { block, .. }
if entry_view_state
.thinking_block_state((entry_ix, chunk_ix), cx)
.0 =>
{
if let Some(md) = block.markdown() {
out.push(md.clone());
}
out.extend(block.markdowns().cloned());
}
AssistantMessageChunk::Thought { .. } => {}
}
@@ -917,21 +912,8 @@ fn collect_markdowns(
.content
.iter()
.filter_map(|content| match content {
ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
Some(markdown.clone())
}
ToolCallContent::ContentBlock(ContentBlock::EmbeddedResource {
markdown: Some(markdown),
..
}) => Some(markdown.clone()),
ToolCallContent::ContentBlock(
ContentBlock::Empty
| ContentBlock::EmbeddedResource { markdown: None, .. }
| ContentBlock::ResourceLink { .. }
| ContentBlock::Image { .. },
)
| ToolCallContent::Diff(_)
| ToolCallContent::Terminal(_) => None,
ToolCallContent::ContentBlock(content) => content.markdown().cloned(),
ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => None,
}),
);
}
@@ -939,15 +921,62 @@ fn collect_markdowns(
AgentThreadEntry::CompletedPlan(entries) => {
out.extend(entries.iter().map(|e| e.content.clone()))
}
AgentThreadEntry::ContextCompaction(compaction)
if entry_view_state.is_compaction_expanded(entry_ix) =>
{
if let Some(summary) = &compaction.summary {
out.push(summary.clone());
}
}
AgentThreadEntry::ContextCompaction(compaction) => out.extend(compaction_markdowns(
compaction,
entry_view_state.is_compaction_expanded(entry_ix),
)),
AgentThreadEntry::Elicitation(_) => {}
AgentThreadEntry::ContextCompaction(_) => {}
}
out
}
fn compaction_markdowns(
compaction: &acp_thread::ContextCompaction,
is_expanded: bool,
) -> impl Iterator<Item = Entity<Markdown>> + '_ {
compaction
.summary
.iter()
.filter_map(|content| content.markdown().cloned())
.chain(compaction.error.iter().cloned())
.filter(move |_| is_expanded)
}
#[cfg(test)]
mod tests {
use super::*;
use acp_thread::{
ContentBlock, ContextCompaction, ContextCompactionId, ContextCompactionStatus,
};
use agent_client_protocol::schema::v1 as acp;
#[gpui::test]
fn test_compaction_markdowns_include_summary_and_error(cx: &mut App) {
let summary = cx.new(|cx| Markdown::new("summary match".into(), None, None, cx));
let unsupported =
cx.new(|cx| Markdown::new("unsupported content match".into(), None, None, cx));
let error = cx.new(|cx| Markdown::new("error match".into(), None, None, cx));
let compaction = ContextCompaction {
id: ContextCompactionId("compaction".into()),
status: ContextCompactionStatus::Failed,
summary: vec![
ContentBlock::Markdown {
markdown: summary.clone(),
},
ContentBlock::Unsupported {
content: acp::ContentBlock::Audio(acp::AudioContent::new(
"YXVkaW8=",
"audio/wav",
)),
markdown: unsupported.clone(),
},
],
error: Some(error.clone()),
};
assert!(compaction_markdowns(&compaction, false).next().is_none());
let markdowns = compaction_markdowns(&compaction, true).collect::<Vec<_>>();
assert_eq!(markdowns, vec![summary, unsupported, error]);
}
}
@@ -1864,7 +1864,7 @@ impl ThreadView {
fn emit_thread_error_telemetry(&self, error: &ThreadError, cx: &mut Context<Self>) {
let (error_kind, acp_error_code, message): (&str, Option<SharedString>, SharedString) =
match error {
ThreadError::PaymentRequired => (
ThreadError::ZedPaymentRequired => (
"payment_required",
None,
"You reached your free usage limit. Upgrade to Zed Pro for more prompts."
@@ -3945,17 +3945,22 @@ impl ThreadView {
cx: &Context<Self>,
) -> AnyElement {
let is_compacting = compaction.is_in_progress();
let summary = compaction.summary.clone();
let summary = &compaction.summary;
let error = compaction.error.clone();
let has_details = !summary.is_empty() || error.is_some();
let is_expanded = self
.entry_view_state
.read(cx)
.is_compaction_expanded(entry_ix);
let details = (is_expanded && has_details).then_some((summary, error));
let id = format!("context-compaction-{entry_ix}");
let header_label = match compaction.status {
let header_label = match &compaction.status {
acp_thread::ContextCompactionStatus::InProgress => "Compacting Context…",
acp_thread::ContextCompactionStatus::Completed => "Context Compacted",
acp_thread::ContextCompactionStatus::Failed => "Compaction Failed",
acp_thread::ContextCompactionStatus::Canceled => "Compaction Canceled",
acp_thread::ContextCompactionStatus::Other(_) => "Context Compaction",
};
let chevron_end = if is_expanded {
IconName::ChevronUp
@@ -3970,13 +3975,13 @@ impl ThreadView {
Button::new(id, header_label)
.label_size(LabelSize::Small)
.loading(is_compacting)
.disabled(is_compacting)
.disabled(!has_details)
.start_icon(
Icon::new(IconName::Compact)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.when(!is_compacting, |this| {
.when(has_details, |this| {
this.end_icon(
Icon::new(chevron_end)
.size(IconSize::XSmall)
@@ -4003,20 +4008,37 @@ impl ThreadView {
.border_color(gpui::transparent_black())
.rounded_sm()
.child(header)
.when_some(summary.filter(|_| is_expanded), |this, summary| {
.when_some(details, |this, (summary, error)| {
this.border_color(self.tool_card_border_color(cx))
.bg(cx.theme().colors().editor_background.opacity(0.2))
.child(
div()
.id(("compaction-summary", entry_ix))
.p_2()
.text_ui(cx)
.child(self.render_markdown(
summary,
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
cx,
)),
)
.when(!summary.is_empty(), |this| {
this.child(
v_flex()
.id(("compaction-summary", entry_ix))
.p_2()
.gap_2()
.text_ui(cx)
.children(summary.iter().enumerate().map(
|(content_ix, content)| {
self.render_output_content_block(
entry_ix, content_ix, content, None, true,
window, cx,
)
},
)),
)
})
.when_some(error, |this, error| {
let mut style =
MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
style.base_text_style.color = Color::Error.color(cx);
this.child(
div()
.id(("compaction-error", entry_ix))
.p_2()
.child(self.render_markdown(error, style, cx)),
)
})
.child(
h_flex()
.border_t_1()
@@ -4048,7 +4070,7 @@ impl ThreadView {
.into_any()
}
fn toggle_compaction_expansion(
pub(super) fn toggle_compaction_expansion(
&mut self,
entry_ix: usize,
window: &mut Window,
@@ -4740,13 +4762,20 @@ impl ThreadView {
let workspace = self.workspace.clone();
let max_output_tokens = self
let (max_input_tokens, max_output_tokens) = self
.as_native_thread(cx)
.and_then(|thread| thread.read(cx).model())
.and_then(|model| model.max_output_tokens())
.unwrap_or(0);
let input_max_label =
crate::humanize_token_count(usage.max_tokens.saturating_sub(max_output_tokens));
.map(|thread| {
let thread = thread.read(cx);
(
thread.input_token_capacity().unwrap_or(usage.max_tokens),
thread
.model()
.and_then(|model| model.max_output_tokens())
.unwrap_or(0),
)
})
.unwrap_or((usage.max_tokens, 0));
let input_max_label = crate::humanize_token_count(max_input_tokens);
let output_max_label = crate::humanize_token_count(max_output_tokens);
let build_tooltip = {
@@ -4782,7 +4811,7 @@ impl ThreadView {
};
if show_split {
let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens);
let input_max_raw = max_input_tokens;
let output_max_raw = max_output_tokens;
let input_ratio = if input_max_raw > 0 {
@@ -6324,43 +6353,31 @@ impl ThreadView {
let mut is_blank = true;
let is_last = entry_ix + 1 == total_entries;
let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
let message_body = v_flex()
.w_full()
.gap_3()
.children(chunks.iter().enumerate().filter_map(
|(chunk_ix, chunk)| match chunk {
AssistantMessageChunk::Message { block, .. } => {
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(), cx)
.into_any_element(),
)
let this_is_blank = !block.visible_content(cx);
is_blank = is_blank && this_is_blank;
(!this_is_blank).then(|| {
div()
.id(("assistant-message-chunk", chunk_ix))
.child(self.render_message_content(
entry_ix, chunk_ix, block, window, cx,
))
.into_any_element()
})
}
AssistantMessageChunk::Thought { block, .. } => {
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(),
let this_is_blank = !block.visible_content(cx);
is_blank = is_blank && this_is_blank;
(!this_is_blank).then(|| {
self.render_thinking_block(
entry_ix, chunk_ix, block, window, cx,
)
.into_any_element()
})
}
},
@@ -6378,7 +6395,7 @@ impl ThreadView {
.when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(self.render_message_context_menu(entry_ix, message_body, cx))
.child(message_body)
.when_some(
self.entry_view_state
.read(cx)
@@ -7429,11 +7446,44 @@ impl ThreadView {
cx.notify();
}
fn render_message_content(
&self,
entry_ix: usize,
chunk_ix: usize,
content: &acp_thread::MessageContent,
window: &Window,
cx: &Context<Self>,
) -> Div {
v_flex().w_full().gap_3().children(
content
.blocks()
.iter()
.enumerate()
.filter(|(_, block)| block.visible_content(cx))
.map(|(block_ix, block)| {
let content = self.render_output_content_block(
entry_ix, block_ix, block, None, false, window, cx,
);
div()
.id(("message-content-block", block_ix))
.debug_selector(move || {
format!("message-content-{entry_ix}-{chunk_ix}-{block_ix}")
})
.child(self.render_message_context_menu(
entry_ix,
block.markdown().cloned(),
content,
cx,
))
}),
)
}
fn render_thinking_block(
&self,
entry_ix: usize,
chunk_ix: usize,
chunk: Entity<Markdown>,
chunk: &acp_thread::MessageContent,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
@@ -7458,6 +7508,7 @@ impl ThreadView {
let panel_bg = cx.theme().colors().panel_background;
v_flex()
.id(("thinking-block", chunk_ix))
.gap_1()
.child(
h_flex()
@@ -7495,7 +7546,10 @@ impl ThreadView {
)
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
this.toggle_thinking_block_expansion(key, window, cx);
})),
}))
.map(|header| {
self.render_message_context_menu(entry_ix, None, header.into_any(), cx)
}),
)
.when(is_open, |this| {
this.child(
@@ -7513,11 +7567,11 @@ impl ThreadView {
this.track_scroll(&scroll_handle)
})
.overflow_hidden()
.child(self.render_markdown(
chunk,
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
cx,
)),
.child(
self.render_message_content(
entry_ix, chunk_ix, chunk, window, cx,
),
),
)
.when(is_constrained, |this| {
this.child(
@@ -7541,6 +7595,7 @@ impl ThreadView {
fn render_message_context_menu(
&self,
entry_ix: usize,
markdown: Option<Entity<Markdown>>,
message_body: AnyElement,
cx: &Context<Self>,
) -> AnyElement {
@@ -7553,44 +7608,18 @@ impl ThreadView {
let focus = window.focused(cx);
let entity = entity.clone();
let workspace = workspace.clone();
let markdown = markdown.clone();
ContextMenu::build(window, cx, move |menu, _, cx| {
let this = entity.read(cx);
let is_at_top = this.list_state.logical_scroll_top().item_ix == 0;
let chunks =
this.thread.read(cx).entries().get(entry_ix).and_then(
|entry| match &entry {
AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks),
_ => None,
},
);
let has_selection = chunks
.map(|chunks| {
chunks.iter().any(|chunk| {
let md = match chunk {
AssistantMessageChunk::Message { block, .. } => {
block.markdown()
}
AssistantMessageChunk::Thought { block, .. } => {
block.markdown()
}
};
md.map_or(false, |m| m.read(cx).has_selection())
})
})
.unwrap_or(false);
let context_menu_link = chunks.and_then(|chunks| {
chunks.iter().find_map(|chunk| {
let md = match chunk {
AssistantMessageChunk::Message { block, .. } => block.markdown(),
AssistantMessageChunk::Thought { block, .. } => block.markdown(),
};
md.and_then(|m| m.read(cx).context_menu_link().cloned())
})
});
let markdown = markdown.as_ref().map(|markdown| markdown.read(cx));
let context_menu_link =
markdown.and_then(|markdown| markdown.context_menu_link().cloned());
let selected_text = markdown
.and_then(|markdown| markdown.context_menu_selected_text().cloned());
let selected_markdown = markdown
.and_then(|markdown| markdown.context_menu_selected_markdown().cloned());
let copy_this_agent_response =
ContextMenuEntry::new("Copy This Agent Response").handler({
@@ -7649,11 +7678,24 @@ impl ThreadView {
})
.separator()
})
.action_disabled_when(
!has_selection,
"Copy Selection",
Box::new(markdown::CopyAsMarkdown),
)
.when_some(selected_text, |menu, selected_text| {
menu.entry("Copy", Some(Box::new(markdown::Copy)), move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
selected_text.to_string(),
));
})
})
.when_some(selected_markdown, |menu, selected_markdown| {
menu.entry(
"Copy as Markdown",
Some(Box::new(markdown::CopyAsMarkdown)),
move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
selected_markdown.to_string(),
));
},
)
})
.item(copy_this_agent_response)
.separator()
.item(scroll_item)
@@ -7697,7 +7739,7 @@ impl ThreadView {
if markdown.trim().is_empty() {
None
} else {
Some(markdown.to_string())
Some(markdown)
}
}
AssistantMessageChunk::Thought { .. } => None,
@@ -7854,7 +7896,13 @@ impl ThreadView {
let output_line_count = output.map(|output| output.content_line_count).unwrap_or(0);
let command_failed = command_finished
&& output.is_some_and(|o| o.exit_status.is_some_and(|status| !status.success()));
&& output.is_some_and(|output| {
output
.exit_status
.exit_code
.is_some_and(|exit_code| exit_code != 0)
|| output.exit_status.signal.is_some()
});
let time_elapsed = if let Some(output) = output {
output.ended_at.duration_since(started_at)
@@ -7925,26 +7973,24 @@ impl ThreadView {
cx.notify();
}
}))
.on_stop({
let terminal = terminal.clone();
cx.listener(move |this, _event, _window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.stop_by_user(cx);
});
if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop {
this.cancel_generation(cx);
}
.when(terminal_data.is_process_backed(), |header| {
header.on_stop({
let terminal = terminal.clone();
cx.listener(move |this, _event, _window, cx| {
terminal.update(cx, |terminal, cx| {
terminal.stop_by_user(cx);
});
if AgentSettings::get_global(cx).cancel_generation_on_terminal_stop {
this.cancel_generation(cx);
}
})
})
})
.when_some(truncated_tooltip, |header, tooltip| {
header.truncated(tooltip)
})
.when(tool_failed || command_failed, |header| {
header.failed(
output
.and_then(|o| o.exit_status)
.map(|status| status.code().unwrap_or(-1)),
)
header.failed(output.and_then(|output| output.exit_status.exit_code))
})
.when_some(tool_call.sandbox_not_applied.as_ref(), |header, reason| {
header.sandbox_warning(self.sandbox_not_applied_warning(reason, cx))
@@ -7975,7 +8021,7 @@ impl ThreadView {
.border_t_1()
.when(tool_failed || command_failed, |card| card.border_dashed())
.border_color(border_color)
.bg(cx.theme().colors().editor_background)
.bg(cx.theme().colors().terminal_background)
.rounded_b_md()
.text_ui_sm(cx)
.h_full()
@@ -9956,6 +10002,19 @@ impl ThreadView {
.into_any_element()
}
fn tool_call_icon_tooltip(
tool_name: Option<&SharedString>,
interrupted_edit: bool,
) -> Option<SharedString> {
let tool_name = tool_name.filter(|name| !name.trim().is_empty());
match (tool_name, interrupted_edit) {
(Some(name), true) => Some(format!("Interrupted Edit\nTool: {name}").into()),
(Some(name), false) => Some(format!("Tool: {name}").into()),
(None, true) => Some("Interrupted Edit".into()),
(None, false) => None,
}
}
fn render_tool_call_label(
&self,
entry_ix: usize,
@@ -9979,10 +10038,9 @@ impl ThreadView {
Icon::new(IconName::ToolPencil).color(Color::Muted)
};
let tool_icon = if is_file && has_failed && has_revealed_diff {
let interrupted_edit = is_file && has_failed && has_revealed_diff;
let tool_icon = if interrupted_edit {
div()
.id(entry_ix)
.tooltip(Tooltip::text("Interrupted Edit"))
.child(DecoratedIcon::new(
file_icon,
Some(
@@ -10064,7 +10122,19 @@ impl ThreadView {
.hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
})
.overflow_hidden()
.child(tool_icon)
.child(
div()
.id(("tool-call-icon", entry_ix))
.flex_none()
.when_some(
Self::tool_call_icon_tooltip(
tool_call.tool_name.as_ref(),
interrupted_edit,
),
|this, tooltip| this.tooltip(Tooltip::text(tooltip)),
)
.child(tool_icon),
)
.child(if has_location {
h_flex()
.id(("open-tool-call-location", entry_ix))
@@ -10188,37 +10258,15 @@ impl ThreadView {
cx: &Context<Self>,
) -> AnyElement {
match content {
ToolCallContent::ContentBlock(content) => {
if let Some((resource, markdown)) = content.embedded_resource() {
self.render_embedded_resource_output(
resource,
markdown.cloned(),
entry_ix,
context_ix,
tool_call,
card_layout,
window,
cx,
)
} else if let Some(resource_link) = content.resource_link() {
self.render_resource_link(resource_link, cx)
} else if let Some(markdown) = content.markdown() {
self.render_markdown_output(
markdown.clone(),
entry_ix,
context_ix,
tool_call,
card_layout,
window,
cx,
)
} else if let Some((image, _)) = content.image() {
let location = tool_call.locations.first().cloned();
self.render_image_output(entry_ix, image.clone(), location, card_layout, cx)
} else {
Empty.into_any_element()
}
}
ToolCallContent::ContentBlock(content) => self.render_output_content_block(
entry_ix,
context_ix,
content,
Some(tool_call),
card_layout,
window,
cx,
),
ToolCallContent::Diff(diff) => {
self.render_diff_editor(entry_ix, diff, tool_call, has_failed, cx)
}
@@ -10235,35 +10283,58 @@ impl ThreadView {
}
}
fn render_embedded_resource_output(
fn render_output_content_block(
&self,
resource: &acp::EmbeddedResource,
markdown: Option<Entity<Markdown>>,
entry_ix: usize,
context_ix: usize,
tool_call: &ToolCall,
content: &acp_thread::ContentBlock,
tool_call: Option<&ToolCall>,
card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
if let Some(markdown) = markdown {
return self.render_markdown_output(
markdown,
entry_ix,
context_ix,
tool_call,
card_layout,
window,
cx,
);
if let Some(markdown) = content.markdown() {
if let Some(tool_call) = tool_call {
self.render_markdown_output(
markdown.clone(),
entry_ix,
context_ix,
tool_call,
card_layout,
window,
cx,
)
} else {
self.render_markdown(
markdown.clone(),
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
cx,
)
.into_any()
}
} else if let Some((resource, _)) = content.embedded_resource() {
if tool_call.is_some() {
self.render_embedded_resource_output(resource, context_ix, card_layout, cx)
} else {
self.render_embedded_resource_label(resource)
}
} else if let Some(resource_link) = content.resource_link() {
self.render_resource_link(resource_link, cx)
} else if let Some((image, _)) = content.image() {
let location = tool_call.and_then(|tool_call| tool_call.locations.first().cloned());
self.render_image_output(entry_ix, image.clone(), location, card_layout, cx)
} else {
Empty.into_any_element()
}
}
let uri = match &resource.resource {
acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(),
acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(),
_ => "",
};
fn render_embedded_resource_output(
&self,
resource: &acp::EmbeddedResource,
context_ix: usize,
card_layout: bool,
cx: &Context<Self>,
) -> AnyElement {
v_flex()
.gap_1()
.map(|this| {
@@ -10279,16 +10350,26 @@ impl ThreadView {
.border_color(self.tool_card_border_color(cx))
}
})
.when(!uri.is_empty(), |this| {
this.child(
Label::new(uri.to_string())
.size(LabelSize::XSmall)
.color(Color::Muted),
)
})
.child(self.render_embedded_resource_label(resource))
.into_any_element()
}
fn render_embedded_resource_label(&self, resource: &acp::EmbeddedResource) -> AnyElement {
let uri = match &resource.resource {
acp::EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.as_str(),
acp::EmbeddedResourceResource::TextResourceContents(text) => text.uri.as_str(),
_ => "",
};
if uri.is_empty() {
Empty.into_any_element()
} else {
Label::new(uri.to_string())
.size(LabelSize::XSmall)
.color(Color::Muted)
.into_any_element()
}
}
fn render_resource_link(
&self,
resource_link: &acp::ResourceLink,
@@ -10487,6 +10568,7 @@ impl ThreadView {
cx: &Context<Self>,
) -> AnyElement {
v_flex()
.debug_selector(|| "agent-output-image".into())
.gap_2()
.map(|this| {
if card_layout {
@@ -11030,7 +11112,7 @@ impl ThreadView {
ThreadError::AuthenticationRequired(error) => {
self.render_authentication_required_error(error.clone(), cx)
}
ThreadError::PaymentRequired => self.render_payment_required_error(cx),
ThreadError::ZedPaymentRequired => self.render_zed_payment_required_error(cx),
ThreadError::RateLimitExceeded { provider } => self.render_error_callout(
"Rate Limit Reached",
format!(
@@ -11163,7 +11245,7 @@ impl ThreadView {
.dismiss_action(self.dismiss_error_button(cx))
}
fn render_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
fn render_zed_payment_required_error(&self, cx: &mut Context<Self>) -> Callout {
const ERROR_MESSAGE: &str =
"You reached your free usage limit. Upgrade to Zed Pro for more prompts.";
@@ -11674,7 +11756,7 @@ impl ThreadView {
.gap_1()
.child(
Label::new(format!(
"Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} bytes; longer ones may consume more model-context tokens."
"Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} characters; longer ones may consume more model-context tokens."
))
.size(LabelSize::Small)
.color(Color::Muted),
@@ -12672,6 +12754,33 @@ mod tests {
use util::path;
use workspace::MultiWorkspace;
#[test]
fn test_tool_call_icon_tooltip() {
for (name, interrupted_edit, expected) in [
(None, false, None),
(Some(" \t\n"), false, None),
(Some("read_file"), false, Some("Tool: read_file")),
(
Some(" mcp__server__**read_file**<raw> "),
false,
Some("Tool: mcp__server__**read_file**<raw> "),
),
(None, true, Some("Interrupted Edit")),
(Some(" \t\n"), true, Some("Interrupted Edit")),
(
Some("edit_file"),
true,
Some("Interrupted Edit\nTool: edit_file"),
),
] {
let name = name.map(SharedString::from);
assert_eq!(
ThreadView::tool_call_icon_tooltip(name.as_ref(), interrupted_edit).as_deref(),
expected,
);
}
}
fn native_command(name: &str) -> acp::AvailableCommand {
acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category(
acp_thread::CommandCategory::Native,
+8 -2
View File
@@ -323,7 +323,11 @@ impl EntryViewState {
entry.insert(element);
}
collections::hash_map::Entry::Occupied(_entry) => {
if is_tool_call_completed && terminal.read(cx).output().is_none() {
let terminal = terminal.read(cx);
if is_tool_call_completed
&& terminal.is_process_backed()
&& terminal.output().is_none()
{
cx.emit(EntryViewEvent {
entry_index: index,
view_event: ViewEvent::TerminalMovedToBackground(id.clone()),
@@ -639,6 +643,7 @@ fn create_terminal(
cx: &mut App,
) -> Entity<TerminalView> {
cx.new(|cx| {
let read_only = !terminal.read(cx).is_process_backed();
let mut view = TerminalView::new(
terminal.read(cx).inner().clone(),
workspace,
@@ -646,7 +651,8 @@ fn create_terminal(
project,
window,
cx,
);
)
.with_read_only(read_only);
view.set_embedded_mode(Some(1000), cx);
view
})
@@ -244,6 +244,7 @@ impl TerminalInlineAssistant {
)?;
let temperature = AgentSettings::temperature_for_model(&model, cx);
let session_id = assist.codegen.read(cx).session_id().to_string();
let mention_set = prompt_editor.read(cx).mention_set().clone();
let load_context_task = load_context(&mention_set, cx);
@@ -263,7 +264,8 @@ impl TerminalInlineAssistant {
request_message.content.push(prompt.into());
LanguageModelRequest {
thread_id: None,
thread_id: Some(session_id),
prompt_cache_key: None,
prompt_id: None,
intent: Some(CompletionIntent::TerminalInlineAssist),
messages: vec![request_message],
@@ -275,6 +277,7 @@ impl TerminalInlineAssistant {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
}
}))
}
+75 -28
View File
@@ -24,7 +24,7 @@ pub struct TerminalToolHeader {
running: bool,
truncated_tooltip: Option<SharedString>,
failed: bool,
exit_code: Option<i32>,
exit_code: Option<u32>,
sandbox_warning: Option<TerminalSandboxWarning>,
on_toggle_expand: Option<ClickHandler>,
on_stop: Option<ClickHandler>,
@@ -70,7 +70,7 @@ impl TerminalToolHeader {
self
}
pub fn failed(mut self, exit_code: Option<i32>) -> Self {
pub fn failed(mut self, exit_code: Option<u32>) -> Self {
self.failed = true;
self.exit_code = exit_code;
self
@@ -178,22 +178,25 @@ impl RenderOnce for TerminalToolHeader {
.color(Color::Muted)
.with_rotate_animation(2),
)
.child(Divider::vertical().color(DividerColor::Border).ml_1())
.child(
IconButton::new(child_id("stop"), IconName::Stop)
.icon_size(IconSize::Small)
.icon_color(Color::Error)
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Stop This Command",
None,
"Also possible by placing your cursor inside the terminal \
and using regular terminal bindings.",
cx,
)
})
.when_some(on_stop, |this, handler| this.on_click(handler)),
)
.when_some(on_stop, |header, handler| {
header
.child(Divider::vertical().color(DividerColor::Border).ml_1())
.child(
IconButton::new(child_id("stop"), IconName::Stop)
.icon_size(IconSize::Small)
.icon_color(Color::Error)
.tooltip(move |_window, cx| {
Tooltip::with_meta(
"Stop This Command",
None,
"Also possible by placing your cursor inside the \
terminal and using regular terminal bindings.",
cx,
)
})
.on_click(handler),
)
})
})
.when_some(truncated_tooltip, |header, tooltip| {
header.child(
@@ -206,16 +209,18 @@ impl RenderOnce for TerminalToolHeader {
)
})
.when(failed, |header| {
header.child(
IconButton::new(child_id("failed"), IconName::Close)
.cursor_style(CursorStyle::Arrow)
.style(ButtonStyle::Transparent)
.icon_size(IconSize::Small)
.icon_color(Color::Error)
.when_some(exit_code, |this, code| {
this.tooltip(Tooltip::text(format!("Exited with code {code}")))
}),
)
header
.debug_selector(|| format!("terminal-tool-failed-{exit_code:?}"))
.child(
IconButton::new(child_id("failed"), IconName::Close)
.cursor_style(CursorStyle::Arrow)
.style(ButtonStyle::Transparent)
.icon_size(IconSize::Small)
.icon_color(Color::Error)
.when_some(exit_code, |this, code| {
this.tooltip(Tooltip::text(format!("Exited with code {code}")))
}),
)
})
.when_some(sandbox_warning, |header, warning| {
let TerminalSandboxWarning {
@@ -247,6 +252,48 @@ impl RenderOnce for TerminalToolHeader {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[gpui::test]
fn running_header_only_offers_stop_when_control_is_available(cx: &mut gpui::TestAppContext) {
struct Header {
stoppable: bool,
stops: usize,
}
impl Render for Header {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().w(px(400.)).child(
TerminalToolHeader::new("test", "test", "/project", true)
.running(true)
.when(self.stoppable, |header| {
header.on_stop(cx.listener(|this, _, _, _| this.stops += 1))
}),
)
}
}
crate::test_support::init_test(cx);
let (header, cx) = cx.add_window_view(|_, _| Header {
stoppable: false,
stops: 0,
});
cx.run_until_parked();
assert!(cx.debug_bounds("ICON-Stop").is_none());
header.update(cx, |header, cx| {
header.stoppable = true;
cx.notify();
});
let stop = cx
.debug_bounds("ICON-Stop")
.expect("owned process Stop control");
cx.simulate_click(stop.center(), gpui::Modifiers::default());
assert_eq!(header.read_with(cx, |header, _| header.stops), 1);
}
}
impl Component for TerminalToolHeader {
fn scope() -> ComponentScope {
ComponentScope::Agent
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/ai_onboarding.rs"
test = false
doctest = false
[features]
default = []
+3 -3
View File
@@ -167,13 +167,13 @@ impl ZedAiOnboarding {
.gap_1()
.child(Headline::new("Welcome to Zed AI"))
.child(
Label::new("Sign in to try Zed Pro free for 14 days.")
Label::new("Sign in to try GPT Luna. Your 14 days begin when you start the trial.")
.color(Color::Muted)
.mb_2(),
)
.child(PlanDefinitions.sign_in_upsell())
.child(
Button::new("sign_in", "Try Zed Pro for Free")
Button::new("sign_in", "Sign In to Try GPT Luna")
.disabled(signing_in)
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
@@ -300,7 +300,7 @@ impl ZedAiOnboarding {
.child(Self::pro_trial_stamp(cx))
.child(Headline::new("Welcome to the Zed Pro Trial"))
.child(
Label::new("Here's what you get for the next 14 days:")
Label::new("Included for 14 days from when your trial started:")
.color(Color::Muted)
.mb_2(),
)
+3 -3
View File
@@ -17,17 +17,17 @@ impl PlanDefinitions {
pub fn sign_in_upsell(&self) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("Unlimited edit predictions"))
.child(ListBulletItem::new("$20 of tokens in Zed agent"))
.child(ListBulletItem::new("$5 of GPT Luna"))
.child(ListBulletItem::new("No credit card required"))
}
pub fn pro_trial(&self, period: bool) -> impl IntoElement {
List::new()
.child(ListBulletItem::new("$20 of tokens in Zed agent"))
.child(ListBulletItem::new("$5 of GPT Luna"))
.child(ListBulletItem::new("Unlimited edit predictions"))
.when(period, |this| {
this.child(ListBulletItem::new(
"Try it out for 14 days, no credit card required",
"14 days from trial start, no credit card required",
))
})
}
+1
View File
@@ -14,6 +14,7 @@ workspace = true
[lib]
path = "src/anthropic.rs"
doctest = false
[dependencies]
anyhow.workspace = true
+206 -1
View File
@@ -423,6 +423,45 @@ pub async fn non_streaming_completion(
}
}
/// Estimates input tokens without generating a message.
///
/// Anthropic's estimate may differ slightly from usage reported during generation.
pub async fn count_input_tokens(
client: &dyn HttpClient,
api_url: &str,
api_key: &str,
request: CountTokensRequest,
beta_headers: Option<String>,
extra_headers: &CustomHeaders,
) -> Result<u64, AnthropicError> {
let (mut response, rate_limits) = send_request_to_route(
client,
api_url,
"/v1/messages/count_tokens",
api_key,
request,
beta_headers,
extra_headers,
)
.await?;
if !response.status().is_success() {
return Err(handle_error_response(response, rate_limits).await);
}
let mut body = String::new();
response
.body_mut()
.read_to_string(&mut body)
.await
.map_err(AnthropicError::ReadResponse)?;
#[derive(Deserialize)]
struct CountTokensResponse {
input_tokens: u64,
}
serde_json::from_str::<CountTokensResponse>(&body)
.map(|response| response.input_tokens)
.map_err(AnthropicError::DeserializeResponse)
}
async fn send_request(
client: &dyn HttpClient,
api_url: &str,
@@ -431,7 +470,28 @@ async fn send_request(
beta_headers: Option<String>,
extra_headers: &CustomHeaders,
) -> Result<(http::Response<AsyncBody>, RateLimitInfo), AnthropicError> {
let uri = format!("{api_url}/v1/messages");
send_request_to_route(
client,
api_url,
"/v1/messages",
api_key,
request,
beta_headers,
extra_headers,
)
.await
}
async fn send_request_to_route(
client: &dyn HttpClient,
api_url: &str,
route: &str,
api_key: &str,
request: impl Serialize,
beta_headers: Option<String>,
extra_headers: &CustomHeaders,
) -> Result<(http::Response<AsyncBody>, RateLimitInfo), AnthropicError> {
let uri = format!("{api_url}{route}");
let mut request_builder = HttpRequest::builder()
.method(Method::POST)
@@ -949,7 +1009,36 @@ pub struct Request {
pub top_p: Option<f32>,
}
/// Input counted by Anthropic, excluding generation and service settings.
#[derive(Serialize, Debug)]
pub struct CountTokensRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<StringOrContents>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<Thinking>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_management: Option<ContextManagement>,
}
impl Request {
pub fn into_count_tokens_request(self) -> CountTokensRequest {
CountTokensRequest {
model: self.model,
messages: self.messages,
tools: self.tools,
tool_choice: self.tool_choice,
system: self.system,
thinking: self.thinking,
context_management: self.context_management,
}
}
/// Configures this request to stop after native compaction.
///
/// Tool definitions remain in the request so the trigger observes the same
@@ -1317,6 +1406,13 @@ fn completion_error_from_anthropic_api_with_status(
ProviderErrorCategory::PromptTooLarge {
tokens: Some(tokens),
}
} else if error
.message
.starts_with("Your credit balance is too low to access the Anthropic API.")
{
// Anthropic sends credit exhaustion as invalid_request_error rather than
// the billing_error documented at https://platform.claude.com/docs/en/api/errors.
ProviderErrorCategory::PaymentRequired
} else {
ProviderErrorCategory::InvalidRequest
}
@@ -1350,6 +1446,115 @@ mod tests {
use super::*;
use http_client::FakeHttpClient;
#[test]
fn count_input_tokens_preserves_input_without_generation_fields() {
let request: Request = serde_json::from_value(serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1234,
"temperature": 0.5,
"system": "Be precise",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Describe this"},
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": "aW1hZ2U="
}}
]}],
"tools": [{"name": "look", "description": "Look up data", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "auto"},
"context_management": {"edits": [{"type": "compact_20260112"}]}
})).expect("generation request");
let generation = serde_json::to_value(&request).expect("generation payload");
assert_eq!(generation["max_tokens"], 1234);
assert_eq!(generation["temperature"], 0.5);
let client = FakeHttpClient::create(move |mut request| async move {
assert_eq!(request.method(), Method::POST);
assert_eq!(
request.uri(),
"https://api.anthropic.com/v1/messages/count_tokens"
);
assert!(
request
.headers()
.get("X-Api-Key")
.is_some_and(|value| value == "test-key")
);
assert_eq!(request.headers()["Anthropic-Version"], "2023-06-01");
assert_eq!(request.headers()["Anthropic-Beta"], "test-beta");
let mut body = String::new();
request.body_mut().read_to_string(&mut body).await?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body)?,
serde_json::json!({
"model": "claude-sonnet-4-6",
"system": "Be precise",
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Describe this"},
{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": "aW1hZ2U="
}}
]}],
"tools": [{"name": "look", "description": "Look up data", "input_schema": {"type": "object"}}],
"tool_choice": {"type": "auto"},
"context_management": {"edits": [{"type": "compact_20260112"}]}
})
);
Ok(http::Response::builder()
.status(200)
.body(AsyncBody::from(r#"{"input_tokens":321}"#))?)
});
let count = futures::executor::block_on(count_input_tokens(
client.as_ref(),
ANTHROPIC_API_URL,
" test-key ",
request.into_count_tokens_request(),
Some("test-beta".into()),
&CustomHeaders::default(),
))
.expect("count succeeds");
assert_eq!(count, 321);
}
#[test]
fn count_input_tokens_preserves_typed_errors() {
for (status, body) in [
(
401,
r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#,
),
(200, "{}"),
] {
let client = FakeHttpClient::create(move |_| async move {
Ok(http::Response::builder()
.status(status)
.body(AsyncBody::from(body))?)
});
let request: Request = serde_json::from_value(serde_json::json!({
"model": "claude-sonnet-4-6", "max_tokens": 100, "messages": []
}))
.expect("request");
let error = futures::executor::block_on(count_input_tokens(
client.as_ref(),
ANTHROPIC_API_URL,
"test-key",
request.into_count_tokens_request(),
None,
&CustomHeaders::default(),
))
.expect_err("count fails");
if status == 401 {
assert!(matches!(
error,
AnthropicError::ApiError {
status: Some(StatusCode::UNAUTHORIZED),
..
}
));
} else {
assert!(matches!(error, AnthropicError::DeserializeResponse(_)));
}
}
}
#[test]
fn list_models_preserves_anthropic_api_errors() {
let client = FakeHttpClient::create(|_| async move {
+29
View File
@@ -290,6 +290,9 @@ pub fn into_anthropic(
cache_mode: AnthropicPromptCacheMode,
compaction_state_owner: &LanguageModelProviderId,
) -> Result<crate::Request> {
let max_output_tokens = request
.max_output_tokens
.map_or(max_output_tokens, |limit| limit.min(max_output_tokens));
let mut new_messages: Vec<Message> = Vec::new();
let mut system_message = String::new();
let mut any_message_wants_cache = false;
@@ -892,6 +895,7 @@ mod tests {
},
],
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -907,6 +911,7 @@ mod tests {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: Some(1024),
};
let anthropic_request = into_anthropic(
@@ -920,6 +925,10 @@ mod tests {
)
.unwrap();
assert_eq!(
serde_json::to_value(&anthropic_request).unwrap()["max_tokens"],
1024
);
// No message content block should carry cache_control anymore; the
// conversation breakpoint is set via top-level automatic caching.
assert_eq!(anthropic_request.messages.len(), 1);
@@ -1000,6 +1009,7 @@ mod tests {
},
],
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -1015,6 +1025,7 @@ mod tests {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: Some(8192),
};
let anthropic_request = into_anthropic(
@@ -1028,6 +1039,10 @@ mod tests {
)
.unwrap();
assert_eq!(
serde_json::to_value(&anthropic_request).unwrap()["max_tokens"],
4096
);
assert!(anthropic_request.cache_control.is_none());
assert!(matches!(
anthropic_request.system,
@@ -1065,6 +1080,7 @@ mod tests {
reasoning_details: None,
}],
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -1075,6 +1091,7 @@ mod tests {
thinking_effort: Some("xhigh".into()),
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
};
let anthropic_request = into_anthropic(
@@ -1116,6 +1133,7 @@ mod tests {
reasoning_details: None,
}],
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -1126,6 +1144,7 @@ mod tests {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
};
let anthropic_request = into_anthropic(
@@ -1185,6 +1204,7 @@ mod tests {
reasoning_details: None,
}],
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -1195,6 +1215,7 @@ mod tests {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
};
let anthropic_request = into_anthropic(
@@ -1246,6 +1267,7 @@ mod tests {
},
],
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -1261,6 +1283,7 @@ mod tests {
thinking_effort: None,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
};
let anthropic_request = into_anthropic(
@@ -1274,6 +1297,10 @@ mod tests {
)
.unwrap();
assert_eq!(
serde_json::to_value(&anthropic_request).unwrap()["max_tokens"],
4096
);
assert!(anthropic_request.cache_control.is_none());
assert!(matches!(
anthropic_request.system,
@@ -1292,6 +1319,7 @@ mod tests {
}],
thinking_effort: None,
thread_id: None,
prompt_cache_key: None,
prompt_id: None,
intent: None,
stop: vec![],
@@ -1301,6 +1329,7 @@ mod tests {
thinking_allowed: true,
speed: None,
compact_at_tokens: None,
max_output_tokens: None,
};
request.messages.push(LanguageModelRequestMessage {
role: Role::Assistant,
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/askpass.rs"
doctest = false
[dependencies]
anyhow.workspace = true
+2
View File
@@ -7,6 +7,8 @@ license = "GPL-3.0-or-later"
[lib]
path = "src/assets.rs"
test = false
doctest = false
[lints]
workspace = true
+16 -5
View File
@@ -3,8 +3,8 @@ use client::Client;
use db::kvp::KeyValueStore;
use futures_lite::StreamExt;
use gpui::{
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, Task, TaskExt,
Window, actions,
App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Global,
Task, TaskExt, Window, actions,
};
use http_client::{HttpClient, HttpClientWithUrl};
use paths::remote_servers_dir;
@@ -173,6 +173,11 @@ impl AutoUpdateStatus {
}
}
pub enum AutoUpdateEvent {
/// A manual check received a release response and found no newer version.
UpToDate,
}
pub struct AutoUpdater {
status: AutoUpdateStatus,
current_version: Version,
@@ -419,6 +424,8 @@ impl UpdateCheckType {
}
}
impl EventEmitter<AutoUpdateEvent> for AutoUpdater {}
impl AutoUpdater {
pub fn get(cx: &mut App) -> Option<Entity<Self>> {
cx.default_global::<GlobalAutoUpdate>().0.clone()
@@ -763,11 +770,15 @@ impl AutoUpdater {
let Some(newer_version) = newer_version else {
this.update(cx, |this, cx| {
let status = match previous_status {
this.status = match previous_status {
AutoUpdateStatus::Updated { .. } => previous_status,
_ => AutoUpdateStatus::Idle,
_ => {
if this.update_check_type.is_manual() {
cx.emit(AutoUpdateEvent::UpToDate);
}
AutoUpdateStatus::Idle
}
};
this.status = status;
cx.notify();
});
return Ok(());
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/auto_update_ui.rs"
test = false
doctest = false
[dependencies]
agent_skills.workspace = true
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/aws_http_client.rs"
test = false
doctest = false
[features]
default = []
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/bedrock.rs"
doctest = false
[features]
default = []
+1
View File
@@ -8,6 +8,7 @@ license = "GPL-3.0-or-later"
[lib]
path = "src/benchmarks.rs"
doctest = false
test = false
[lints]
workspace = true
+84 -4
View File
@@ -1,14 +1,20 @@
use std::{path::PathBuf, sync::Arc};
use benchmarks::bench_utils::random_rust_file;
use editor::{
Editor, EditorMode, MultiBuffer,
actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines},
};
use gpui::{App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _};
use gpui::{
App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _, UpdateGlobal as _,
};
use indoc::{formatdoc, indoc};
use language::{Buffer, Capability, DiskState, File, LocalFile};
use language::{Buffer, Capability, DiskState, File, LocalFile, Rope};
use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore, WorktreeId};
use settings::{
DisplayIn, LocalSettingsKind, LocalSettingsPath, SettingsStore, ShowMinimap, WorktreeId,
};
use theme::ActiveTheme as _;
use util::{RandomCharIter, paths::PathStyle, rel_path::RelPath};
use zed_actions::editor::{MoveDown, MoveUp};
@@ -352,6 +358,78 @@ fn jetbrains_editorconfig() -> String {
content
}
#[gpui::bench]
fn editor_render_highlighted(cx: &mut BenchAppContext) {
init_context(cx);
render_highlighted_editor(cx);
}
#[gpui::bench]
fn editor_render_highlighted_minimap(cx: &mut BenchAppContext) {
init_context(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
let minimap = settings.editor.minimap.get_or_insert_default();
minimap.show = Some(ShowMinimap::Always);
minimap.display_in = Some(DisplayIn::AllEditors);
});
});
});
render_highlighted_editor(cx);
}
fn render_highlighted_editor(cx: &mut BenchAppContext) {
let mut rng = StdRng::seed_from_u64(1);
let text = random_rust_file(&mut rng, 10_000).join("\n");
let language = language::rust_lang();
let syntax_theme = cx.update(|cx| {
let syntax_theme = cx.theme().syntax().clone();
language.set_theme(&syntax_theme);
syntax_theme
});
let probe = "fn main() {}";
assert!(
!language
.highlight_text(&Rope::from(probe), 0..probe.len())
.is_empty(),
"the benchmark language must resolve syntax highlights against the theme"
);
let buffer = cx.update(|cx| {
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
cx.new(|cx| MultiBuffer::singleton(buffer, cx))
});
cx.run_until_idle();
let mut window = cx.add_empty_window();
let editor = window.update(|window, cx| {
let editor = window.replace_root(cx, |window, cx| {
let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx);
editor.set_style(
editor::EditorStyle {
syntax: syntax_theme.clone(),
..editor::EditorStyle::default()
},
window,
cx,
);
editor
});
window.focus(&editor.focus_handle(cx), cx);
editor
});
let mut move_down = true;
cx.bench_renderer(editor, move |editor, window, cx| {
if move_down {
editor.move_down(&MoveDown, window, cx);
} else {
editor.move_up(&MoveUp, window, cx);
}
move_down = !move_down;
});
}
fn init_context(cx: &mut BenchAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
@@ -375,6 +453,8 @@ gpui::bench_group!(
editor_multi_cursor_input,
open_editor_with_one_long_line,
editor_render,
editor_render_with_editorconfig
editor_render_with_editorconfig,
editor_render_highlighted,
editor_render_highlighted_minimap
);
gpui::bench_main!(benches);
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[lib]
path = "src/breadcrumbs.rs"
doctest = false
test = false
[dependencies]
gpui.workspace = true
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/buffer_diff.rs"
doctest = false
[features]
test-support = ["settings"]
+1
View File
@@ -132,6 +132,7 @@ impl RefreshLlmTokenListener {
MessageToClient::UserUpdated => {
this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx));
}
MessageToClient::NotificationsUpdated => {}
}
}
}
+21 -21
View File
@@ -902,29 +902,29 @@ impl UserStore {
}
fn handle_message_to_client(this: WeakEntity<Self>, message: &MessageToClient, cx: &App) {
cx.spawn(async move |cx| {
match message {
MessageToClient::UserUpdated => {
let (cloud_client, system_id) = cx
.update(|cx| {
this.read_with(cx, |this, _cx| {
this.client.upgrade().map(|client| {
let system_id =
client.telemetry().system_id().map(|id| id.to_string());
(client.cloud_client(), system_id)
})
})
})?
.ok_or(anyhow::anyhow!("Failed to get Cloud client"))?;
match message {
MessageToClient::UserUpdated => {}
MessageToClient::NotificationsUpdated => return,
}
let response = cloud_client.get_authenticated_user(system_id).await?;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.update_authenticated_user(response, cx);
cx.spawn(async move |cx| {
let (cloud_client, system_id) = cx
.update(|cx| {
this.read_with(cx, |this, _cx| {
this.client.upgrade().map(|client| {
let system_id = client.telemetry().system_id().map(|id| id.to_string());
(client.cloud_client(), system_id)
})
})?;
}
}
})
})?
.ok_or(anyhow::anyhow!("Failed to get Cloud client"))?;
let response = cloud_client.get_authenticated_user(system_id).await?;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.update_authenticated_user(response, cx);
})
})?;
anyhow::Ok(())
})
+8 -1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/cloud_api_client.rs"
doctest = false
[dependencies]
anyhow.workspace = true
@@ -21,9 +22,15 @@ http_client.workspace = true
parking_lot.workspace = true
serde.workspace = true
serde_json.workspace = true
async-lock.workspace = true
thiserror.workspace = true
yawc.workspace = true
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# Workspace inheritance would re-enable defaults, including blocking synchronization.
async-lock = { version = "3.4.2", default-features = false }
[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies]
async-lock.workspace = true
[target.'cfg(not(target_family = "wasm"))'.dependencies]
gpui_tokio.workspace = true
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/cloud_api_types.rs"
doctest = false
[dependencies]
anyhow.workspace = true
@@ -8,10 +8,12 @@ pub const PROTOCOL_VERSION: u32 = 0;
pub const PROTOCOL_VERSION_HEADER_NAME: &str = "x-zed-protocol-version";
/// A message from Cloud to the Zed client.
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageToClient {
/// The user was updated and should be refreshed.
UserUpdated,
/// The user's notifications were updated.
NotificationsUpdated,
}
impl MessageToClient {
@@ -26,3 +28,30 @@ impl MessageToClient {
ciborium::from_reader(data).context("failed to deserialize message")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notifications_updated_message_round_trips() -> Result<()> {
let message = MessageToClient::NotificationsUpdated;
assert_eq!(
MessageToClient::deserialize(&message.serialize()?)?,
message
);
Ok(())
}
#[test]
fn notifications_updated_message_uses_the_expected_wire_format() -> Result<()> {
assert_eq!(
MessageToClient::NotificationsUpdated.serialize()?,
b"\x74NotificationsUpdated"
);
Ok(())
}
}
+1
View File
@@ -14,6 +14,7 @@ workspace = true
[lib]
path = "src/cloud_llm_client.rs"
doctest = false
[dependencies]
anyhow.workspace = true
@@ -91,7 +91,6 @@ impl FromStr for UsageLimit {
#[strum(serialize_all = "snake_case")]
pub enum LanguageModelProvider {
Anthropic,
Baseten,
OpenAi,
Google,
XAi,
+3 -3
View File
@@ -1,12 +1,14 @@
[package]
name = "codestral"
version = "0.1.0"
edition = "2021"
edition.workspace = true
publish = false
license = "GPL-3.0-or-later"
[lib]
path = "src/codestral.rs"
test = false
doctest = false
[dependencies]
anyhow.workspace = true
@@ -24,5 +26,3 @@ serde_json.workspace = true
text.workspace = true
zed_credentials_provider.workspace = true
zeta_prompt.workspace = true
[dev-dependencies]
+4 -4
View File
@@ -239,10 +239,10 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate {
let snapshot = buffer.read(cx).snapshot();
// Check if current completion is still valid
if let Some(current_completion) = self.current_completion.as_ref() {
if current_completion.interpolate(&snapshot).is_some() {
return;
}
if let Some(current_completion) = self.current_completion.as_ref()
&& current_completion.interpolate(&snapshot).is_some()
{
return;
}
let http_client = self.http_client.clone();
+3 -2
View File
@@ -19,6 +19,7 @@ test-support = ["sqlite"]
[lib]
test = false
doctest = false
[[test]]
name = "collab_tests"
@@ -30,8 +31,8 @@ anyhow.workspace = true
async-trait.workspace = true
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots" ] }
aws-config = { version = "1.1.5" }
aws-sdk-kinesis = "1.51.0"
aws-sdk-s3 = { version = "1.15.0" }
aws-sdk-kinesis = { version = "1.51.0", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-s3 = { version = "1.15.0", default-features = false, features = ["sigv4a", "http-1x", "default-https-client", "rt-tokio"] }
axum = { version = "0.6", features = ["json", "headers", "ws"] }
chrono.workspace = true
clock.workspace = true
+2
View File
@@ -183,6 +183,7 @@ async fn main() -> Result<()> {
Ok(())
}
#[expect(clippy::result_large_err)]
async fn setup_app_database(config: &Config) -> Result<()> {
let db_options = db::ConnectOptions::new(config.database_url.clone());
let mut db = Database::new(db_options).await?;
@@ -196,6 +197,7 @@ async fn handle_root(Extension(mode): Extension<ServiceMode>) -> String {
format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown"))
}
#[expect(clippy::result_large_err)]
async fn handle_liveness_probe(app_state: Option<Extension<Arc<AppState>>>) -> Result<String> {
if let Some(state) = app_state {
state.db.project_count_excluding_admins().await?;
+28 -1
View File
@@ -376,6 +376,9 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::LspExtExpandMacro>)
.add_request_handler(
forward_read_only_project_request::<proto::LspExtExpandAbbreviation>,
)
.add_request_handler(forward_read_only_project_request::<proto::LspExtOpenDocs>)
.add_request_handler(forward_mutating_project_request::<proto::LspExtRunnables>)
.add_request_handler(
@@ -398,6 +401,7 @@ impl Server {
forward_mutating_project_request::<proto::ResolveCompletionDocumentation>,
)
.add_request_handler(forward_mutating_project_request::<proto::ApplyCodeAction>)
.add_request_handler(forward_mutating_project_request::<proto::ExecuteLspCommand>)
.add_request_handler(forward_mutating_project_request::<proto::PrepareRename>)
.add_request_handler(forward_mutating_project_request::<proto::PerformRename>)
.add_request_handler(forward_mutating_project_request::<proto::ReloadBuffers>)
@@ -417,7 +421,7 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::SaveBuffer>)
.add_request_handler(forward_mutating_project_request::<proto::BlameBuffer>)
.add_request_handler(lsp_query)
.add_message_handler(broadcast_project_message_from_host::<proto::LspQueryResponse>)
.add_message_handler(forward_lsp_query_response)
.add_request_handler(forward_mutating_project_request::<proto::RestartLanguageServers>)
.add_request_handler(forward_mutating_project_request::<proto::StopLanguageServers>)
.add_request_handler(forward_mutating_project_request::<proto::LinkedEditingRange>)
@@ -433,6 +437,9 @@ impl Server {
broadcast_project_message_from_host::<proto::RefreshDocumentColors>,
)
.add_message_handler(broadcast_project_message_from_host::<proto::RefreshDocumentLinks>)
.add_message_handler(
broadcast_project_message_from_host::<proto::RefreshDocumentHighlights>,
)
.add_message_handler(broadcast_project_message_from_host::<proto::RefreshFoldingRanges>)
.add_message_handler(
broadcast_project_message_from_host::<proto::RefreshDocumentSymbols>,
@@ -2470,6 +2477,26 @@ async fn lsp_query(
}
}
async fn forward_lsp_query_response(
request: proto::LspQueryResponse,
session: MessageContext,
) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
session
.db()
.await
.check_user_is_project_host(project_id, session.connection_id)
.await?;
if let Some(peer_id) = request.peer_id {
session
.peer
.forward_send(session.connection_id, peer_id.into(), request)?;
Ok(())
} else {
broadcast_project_message_from_host(request, session).await
}
}
/// Notify other participants that a new buffer has been created
async fn create_buffer_for_peer(
request: proto::CreateBufferForPeer,
File diff suppressed because it is too large. Load diff
+82 -1
View File
@@ -21,7 +21,7 @@ use gpui::{
};
use project::{
ProjectPath,
git_store::{CommitDataState, Repository},
git_store::{CommitDataState, MAX_CONCURRENT_BLOB_READS, Repository},
};
use rand::{SeedableRng, rngs::StdRng};
use serde_json::json;
@@ -449,6 +449,87 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext)
});
}
#[gpui::test]
async fn test_blob_read_rpcs_are_bounded_on_host(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
const FILE_COUNT: usize = MAX_CONCURRENT_BLOB_READS + 4;
let names = (0..FILE_COUNT)
.map(|index| format!("f{index:02}.txt"))
.collect::<Vec<_>>();
let mut tree = serde_json::Map::new();
tree.insert(".git".to_owned(), json!({}));
for (index, name) in names.iter().enumerate() {
tree.insert(name.clone(), json!(format!("new-{index:02}\n")));
}
client_a
.fs()
.insert_tree(path!("/dir"), serde_json::Value::Object(tree))
.await;
let merge_base = names
.iter()
.enumerate()
.map(|(index, name)| (name.as_str(), format!("old-{index:02}\n")))
.collect::<Vec<_>>();
let dot_git = Path::new(path!("/dir/.git"));
let oids = client_a
.fs()
.set_merge_base_content_for_repo(dot_git, &merge_base);
let gate = client_a.fs().install_blob_read_gate_for_repo(dot_git);
let (project_local, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| {
call.share_project(project_local.clone(), cx)
})
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let project_c = client_c.join_remote_project(project_id, cx_c).await;
executor.run_until_parked();
// Two guests exceed the cap combined; only the host's own limiter holds them to it.
let mut diffs = Vec::with_capacity(names.len() * 2);
for (project, cx) in [(&project_b, &mut *cx_b), (&project_c, &mut *cx_c)] {
let git_store = project.read_with(cx, |project, _| project.git_store().clone());
let repo = project.read_with(cx, |project, cx| project.active_repository(cx).unwrap());
for (index, name) in names.iter().enumerate() {
let buffer = project
.update(cx, |project, cx| {
project.open_buffer((worktree_id, rel_path(name)), cx)
})
.await
.unwrap();
diffs.push(git_store.update(cx, |git_store, cx| {
git_store.open_diff_since(Some(oids[index]), buffer, repo.clone(), cx)
}));
}
}
executor.run_until_parked();
assert_eq!(gate.peak_concurrent(), MAX_CONCURRENT_BLOB_READS);
gate.open();
executor.run_until_parked();
for diff in diffs {
diff.await.unwrap();
}
}
#[gpui::test]
async fn test_remote_git_worktrees(
executor: BackgroundExecutor,
@@ -9,7 +9,10 @@ use client::{RECEIVE_TIMEOUT, User};
use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT};
use collections::{BTreeMap, HashMap, HashSet};
use fs::{FakeFs, Fs as _, RemoveOptions};
use futures::{StreamExt as _, channel::mpsc};
use futures::{
StreamExt as _,
channel::{mpsc, oneshot},
};
use git::{
repository::repo_path,
status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode},
@@ -5489,6 +5492,107 @@ async fn test_references(
});
}
#[gpui::test]
async fn test_concurrent_guest_lsp_requests_do_not_cancel_each_other(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let capabilities = lsp::ServerCapabilities {
references_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "my-fake-lsp-adapter",
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
for client in [&client_b, &client_c] {
client.language_registry().add(rust_lang());
client.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
name: "my-fake-lsp-adapter",
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
}
client_a
.fs()
.insert_tree(
path!("/root"),
json!({
"main.rs": "const ONE: usize = 1;"
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let project_c = client_c.join_remote_project(project_id, cx_c).await;
let (buffer_b, _handle_b) = project_b
.update(cx_b, |project, cx| {
project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let (buffer_c, _handle_c) = project_c
.update(cx_c, |project, cx| {
project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
cx_c.run_until_parked();
let (request_started_tx, mut request_started_rx) = mpsc::unbounded();
let _reference_requests = fake_language_server
.set_request_handler::<lsp::request::References, _, _>(move |params, _| {
let (response_tx, response_rx) = oneshot::channel();
request_started_tx
.unbounded_send((params.text_document_position.position, response_tx))
.unwrap();
async move { response_rx.await.unwrap() }
});
let references_b = project_b.update(cx_b, |project, cx| project.references(&buffer_b, 1, cx));
let (position_b, response_b) = request_started_rx.next().await.unwrap();
assert_eq!(position_b, lsp::Position::new(0, 1));
let references_c = project_c.update(cx_c, |project, cx| project.references(&buffer_c, 7, cx));
let (position_c, response_c) = request_started_rx.next().await.unwrap();
assert_eq!(position_c, lsp::Position::new(0, 7));
assert!(!response_b.is_canceled());
assert!(!response_c.is_canceled());
response_b.send(Ok(Some(Vec::new()))).unwrap();
response_c.send(Ok(Some(Vec::new()))).unwrap();
assert_eq!(references_b.await.unwrap().unwrap(), Vec::new());
assert_eq!(references_c.await.unwrap().unwrap(), Vec::new());
}
#[gpui::test(iterations = 10)]
async fn test_project_search(
executor: BackgroundExecutor,
@@ -7586,3 +7690,82 @@ async fn test_guest_can_rejoin_shared_project_after_leaving_call(
);
})
}
/// Tests that a guest's project search does not return the contents of files the host has marked
/// private.
#[gpui::test]
async fn test_project_search_excludes_private_files(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |file| {
file.project.worktree.private_files = Some(vec!["**/.env".to_string()].into());
});
});
});
client_a
.fs()
.insert_tree(
"/root",
json!({
"dir-1": {
"a.txt": "the secret is out",
".env": "API_KEY=secret",
}
}),
)
.await;
let (project_a, _) = client_a.build_local_project("/root/dir-1", cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let mut results = HashMap::default();
let search_rx = project_b.update(cx_b, |project, cx| {
project.search(
SearchQuery::text(
"secret",
false,
false,
false,
Default::default(),
Default::default(),
false,
None,
)
.unwrap(),
cx,
)
});
while let Ok(result) = search_rx.rx.recv().await {
if let SearchResult::Buffer { buffer, ranges } = result {
results.entry(buffer).or_insert(ranges);
}
}
let mut paths = results
.into_keys()
.map(|buffer| buffer.read_with(cx_b, |buffer, cx| buffer.file().unwrap().full_path(cx)))
.collect::<Vec<_>>();
paths.sort();
assert_eq!(
paths,
&[PathBuf::from("dir-1/a.txt")],
"the guest's search returned a file the host marked private"
);
}
@@ -927,12 +927,12 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
let matching_server_ids = log_store
.language_servers
.iter()
.filter_map(|(server_id, state)| {
.filter_map(|(key, state)| {
state
.name
.as_ref()
.is_some_and(|name| name.0 == "the-language-server")
.then_some(*server_id)
.then_some(key.server_id)
})
.collect::<Vec<_>>();
assert_eq!(matching_server_ids, vec![first_server_id]);
@@ -958,10 +958,6 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
statuses[0].0, restarted_server_id,
"restarting a remote language server should publish the replacement server id"
);
assert_ne!(
statuses[0].0, first_server_id,
"restarting a remote language server should remove the previous server id"
);
assert_eq!(statuses[0].1.name.0, "the-language-server");
});
cx_a.read_global::<GlobalLogStore, _>(|global, cx| {
@@ -969,12 +965,12 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
let matching_server_ids = log_store
.language_servers
.iter()
.filter_map(|(server_id, state)| {
.filter_map(|(key, state)| {
state
.name
.as_ref()
.is_some_and(|name| name.0 == "the-language-server")
.then_some(*server_id)
.then_some(key.server_id)
})
.collect::<Vec<_>>();
assert_eq!(
@@ -982,10 +978,6 @@ async fn test_ssh_restarting_language_server_replaces_remote_status(
vec![restarted_server_id],
"restarting a remote language server should replace the old log store entry"
);
assert!(
!log_store.language_servers.contains_key(&first_server_id),
"restarting a remote language server should remove the previous log store entry"
);
});
}
+377 -33
View File
@@ -1,3 +1,4 @@
mod command_palette_settings;
mod persistence;
use std::{
@@ -7,28 +8,35 @@ use std::{
time::Duration,
};
use anyhow::Context as _;
use client::parse_zed_link;
use command_palette_hooks::{
CommandInterceptItem, CommandInterceptResult, CommandPaletteFilter,
GlobalCommandPaletteInterceptor,
};
use command_palette_settings::CommandPaletteSettings;
use fuzzy_nucleo::{StringMatch, StringMatchCandidate};
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window,
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseButton,
ParentElement, Render, Styled, Task, TaskExt, WeakEntity, Window, actions,
};
use persistence::CommandPaletteDB;
use picker::Direction;
use picker::{Picker, PickerDelegate};
use postage::{sink::Sink, stream::Stream};
use settings::Settings;
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
use ui::{
ButtonLike, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*,
};
use util::ResultExt;
use workspace::{ModalView, Workspace, WorkspaceSettings};
use zed_actions::{OpenZedUrl, command_palette::Toggle};
actions!(command_palette, [RemoveSelected]);
pub fn init(cx: &mut App) {
CommandPaletteSettings::register(cx);
command_palette_hooks::init(cx);
cx.observe_new(CommandPalette::register).detach();
}
@@ -120,8 +128,9 @@ impl CommandPalette {
}
Some(Command {
name: humanize_action_name(action.name()),
name: SharedString::from(humanize_action_name(action.name())),
action,
usage: None,
})
})
.collect();
@@ -136,6 +145,7 @@ impl CommandPalette {
let picker = cx.new(|cx| {
// One-shot action; there's nothing to reopen.
let picker = Picker::uniform_list(delegate, window, cx)
.initial_width(rems(38.0))
.reopenable(false, cx)
.show_scrollbar(true);
picker.set_query(query, window, cx);
@@ -148,6 +158,22 @@ impl CommandPalette {
self.picker
.update(cx, |picker, cx| picker.set_query(query, window, cx))
}
fn remove_selected(&mut self, _: &RemoveSelected, window: &mut Window, cx: &mut Context<Self>) {
self.picker.update(cx, |picker, cx| {
let delegate = &mut picker.delegate;
let Some(command_name) = delegate
.selected_command()
.filter(|command| command.usage.is_some())
.map(|command| command.name.clone())
else {
return;
};
delegate
.remove_command_history(command_name, window, cx)
.detach();
});
}
}
impl EventEmitter<DismissEvent> for CommandPalette {}
@@ -159,9 +185,10 @@ impl Focusable for CommandPalette {
}
impl Render for CommandPalette {
fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("CommandPalette")
.on_action(cx.listener(Self::remove_selected))
.child(self.picker.clone())
}
}
@@ -183,8 +210,9 @@ pub struct CommandPaletteDelegate {
}
struct Command {
name: String,
name: SharedString,
action: Box<dyn Action>,
usage: Option<CommandUsage>,
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -283,6 +311,7 @@ impl Clone for Command {
Self {
name: self.name.clone(),
action: self.action.boxed_clone(),
usage: self.usage,
}
}
}
@@ -298,13 +327,13 @@ impl CommandPaletteDelegate {
command_palette,
workspace,
all_commands: commands.clone(),
matches: vec![],
matches: Vec::new(),
commands,
selected_ix: 0,
previous_focus_handle,
latest_query: String::new(),
updating_matches: None,
query_history: Default::default(),
query_history: QueryHistory::default(),
}
}
@@ -333,13 +362,15 @@ impl CommandPaletteDelegate {
{
matches.remove(idx);
}
let string = SharedString::from(string);
commands.push(Command {
name: string.clone(),
action,
usage: None,
});
new_matches.push(StringMatch {
candidate_id: commands.len() - 1,
string: string.into(),
string,
positions,
score: 0.0,
})
@@ -359,13 +390,17 @@ impl CommandPaletteDelegate {
/// Hit count for each command in the palette.
/// We only account for commands triggered directly via command palette and not by e.g. keystrokes because
/// if a user already knows a keystroke for a command, they are unlikely to use a command palette to look for it.
fn command_usage(&self, cx: &App) -> HashMap<String, CommandUsage> {
fn command_usage(&self, cx: &App) -> HashMap<SharedString, CommandUsage> {
if !CommandPaletteSettings::get_global(cx).use_command_history {
return HashMap::new();
}
if let Ok(commands) = CommandPaletteDB::global(cx).list_commands_used() {
commands
.into_iter()
.map(|command| {
(
command.command_name,
SharedString::from(command.command_name),
CommandUsage {
last_invoked: command.last_invoked.unix_timestamp(),
invocations: command.invocations,
@@ -378,6 +413,86 @@ impl CommandPaletteDelegate {
}
}
fn remove_command_history(
&mut self,
command_name: SharedString,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let db = CommandPaletteDB::global(cx);
cx.spawn_in(window, async move |picker, cx| {
if db
.delete_command_history(command_name.to_string())
.await
.with_context(|| format!("removing {command_name:?} from command history"))
.log_err()
.is_none()
{
return;
}
picker
.update_in(cx, |picker, window, cx| {
picker.delegate.query_history = QueryHistory::default();
picker.refresh(window, cx);
})
.ok();
})
}
fn render_history_button(
ix: usize,
command_name: SharedString,
cx: &mut Context<Picker<Self>>,
) -> impl IntoElement {
h_flex()
.id(("command-history", ix))
.debug_selector(move || format!("command-history-{ix}"))
.group("command-history-button")
.on_mouse_down(MouseButton::Left, |_, window, cx| {
cx.stop_propagation();
window.prevent_default();
})
.on_mouse_up(MouseButton::Right, |_, window, cx| {
cx.stop_propagation();
window.prevent_default();
})
.child(
ButtonLike::new(("remove-command-history", ix))
.aria_label("Remove from Command History")
.tooltip(Tooltip::for_action_title(
"Remove from Command History",
&RemoveSelected,
))
.child(
h_flex()
.relative()
.child(
h_flex()
.group_hover("command-history-button", |this| this.invisible())
.child(
Icon::new(IconName::HistoryRerun)
.color(Color::Muted)
.size(IconSize::Small),
),
)
.child(
h_flex()
.absolute()
.inset_0()
.visible_on_hover("command-history-button")
.child(Icon::new(IconName::Close).size(IconSize::Small)),
),
)
.on_click(cx.listener(move |picker, _, window, cx| {
window.prevent_default();
picker
.delegate
.remove_command_history(command_name.clone(), window, cx)
.detach();
})),
)
}
fn selected_command(&self) -> Option<&Command> {
if self.matches.is_empty() {
return None;
@@ -489,12 +604,10 @@ impl PickerDelegate for CommandPaletteDelegate {
let query = normalize_action_query(query_str);
let query_for_link = query_str.to_string();
async move {
commands.sort_by_key(|action| {
(
Reverse(command_usage.get(&action.name).copied()),
action.name.clone(),
)
});
for command in &mut commands {
command.usage = command_usage.get(&command.name).copied();
}
commands.sort_by_key(|command| (Reverse(command.usage), command.name.clone()));
let candidates = commands
.iter()
@@ -515,7 +628,7 @@ impl PickerDelegate for CommandPaletteDelegate {
let used_commands = commands
.iter()
.take_while(|command| command_usage.contains_key(&command.name))
.take_while(|command| command.usage.is_some())
.count();
matches.sort_by_key(|string_match| {
if string_match.candidate_id < used_commands {
@@ -668,15 +781,31 @@ impl PickerDelegate for CommandPaletteDelegate {
.w_full()
.py_px()
.justify_between()
.child(HighlightedLabel::new(
command.name.clone(),
matching_command.positions.clone(),
))
.child(KeyBinding::for_action_in(
&*command.action,
&self.previous_focus_handle,
cx,
)),
.gap_2()
.child(
HighlightedLabel::new(
command.name.clone(),
matching_command.positions.clone(),
)
.truncate(),
)
.child(
h_flex()
.flex_shrink_0()
.gap_2()
.child(KeyBinding::for_action_in(
&*command.action,
&self.previous_focus_handle,
cx,
))
.when(command.usage.is_some(), |this| {
this.child(Self::render_history_button(
ix,
command.name.clone(),
cx,
))
}),
),
),
)
}
@@ -909,7 +1038,7 @@ mod tests {
editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx))
});
cx.simulate_keystrokes("cmd-shift-p");
cx.dispatch_action(Toggle);
let palette = workspace.update(cx, |workspace, cx| {
workspace
@@ -933,13 +1062,75 @@ mod tests {
assert_eq!(palette.delegate.matches[0].string, "editor: backspace");
});
cx.simulate_keystrokes("enter");
cx.dispatch_action(menu::Confirm);
workspace.update(cx, |workspace, cx| {
assert!(workspace.active_modal::<CommandPalette>(cx).is_none());
assert_eq!(editor.read(cx).text(cx), "ab")
});
cx.dispatch_action(Toggle);
let history_button = cx.debug_bounds("command-history-0").unwrap();
cx.simulate_mouse_move(history_button.center(), None, gpui::Modifiers::default());
cx.simulate_mouse_down(
history_button.center(),
MouseButton::Right,
gpui::Modifiers::default(),
);
cx.simulate_mouse_up(
history_button.center(),
MouseButton::Right,
gpui::Modifiers::default(),
);
cx.simulate_mouse_down(
history_button.center(),
MouseButton::Left,
gpui::Modifiers::default(),
);
let label_position = history_button.center() - gpui::point(px(100.0), px(0.0));
cx.simulate_mouse_move(
label_position,
MouseButton::Left,
gpui::Modifiers::default(),
);
cx.simulate_mouse_up(
label_position,
MouseButton::Left,
gpui::Modifiers::default(),
);
workspace.read_with(cx, |workspace, cx| {
assert!(workspace.active_modal::<CommandPalette>(cx).is_some());
assert_eq!(editor.read(cx).text(cx), "ab");
});
assert_eq!(
db.get_command_usage("editor: backspace")
.unwrap()
.unwrap()
.invocations,
1
);
cx.simulate_mouse_move(history_button.center(), None, gpui::Modifiers::default());
cx.simulate_click(history_button.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(db.get_command_usage("editor: backspace").unwrap(), None);
workspace.update_in(cx, |workspace, window, cx| {
assert_eq!(editor.read(cx).text(cx), "ab");
let palette = workspace.active_modal::<CommandPalette>(cx).unwrap();
let picker = palette.read(cx).picker.read(cx);
assert_eq!(
picker
.delegate
.matches
.iter()
.filter(|matching_command| matching_command.string == "editor: backspace")
.count(),
1,
);
workspace.hide_modal(window, cx);
});
// Add namespace filter, and redeploy the palette
cx.update(|_window, cx| {
CommandPaletteFilter::update_global(cx, |filter, _| {
@@ -947,7 +1138,7 @@ mod tests {
});
});
cx.simulate_keystrokes("cmd-shift-p");
cx.dispatch_action(Toggle);
cx.simulate_input("bcksp");
let palette = workspace.update(cx, |workspace, cx| {
@@ -1029,8 +1220,8 @@ mod tests {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.simulate_keystrokes("cmd-n");
cx.simulate_keystrokes("cmd-shift-p");
cx.dispatch_action(workspace::NewFile);
cx.dispatch_action(Toggle);
let palette = workspace.update(cx, |workspace, cx| {
workspace.active_modal::<CommandPalette>(cx).unwrap()
@@ -1042,6 +1233,14 @@ mod tests {
});
cx.run_until_parked();
let unranked_names = picker.read_with(cx, |picker, _| {
picker
.delegate
.matches
.iter()
.map(|matching_command| matching_command.string.to_string())
.collect::<Vec<_>>()
});
let (worst_match, second_worst_match, match_count) = picker.read_with(cx, |picker, _| {
let matches = &picker.delegate.matches;
(
@@ -1081,6 +1280,150 @@ mod tests {
assert_eq!(names[1], second_worst_match);
assert_eq!(names.len(), match_count);
});
picker.update_in(cx, |picker, window, cx| {
CommandPaletteSettings::override_global(
CommandPaletteSettings {
use_command_history: false,
},
cx,
);
picker.refresh(window, cx);
});
cx.run_until_parked();
cx.dispatch_action(RemoveSelected);
picker.read_with(cx, |picker, _| {
assert_eq!(
picker
.delegate
.matches
.iter()
.map(|matching_command| matching_command.string.to_string())
.collect::<Vec<_>>(),
unranked_names
);
assert!(
picker
.delegate
.commands
.iter()
.all(|command| command.usage.is_none())
);
});
assert_eq!(db.list_commands_used().unwrap().len(), 2);
picker.update_in(cx, |picker, window, cx| {
CommandPaletteSettings::override_global(
CommandPaletteSettings {
use_command_history: true,
},
cx,
);
picker.refresh(window, cx);
});
cx.run_until_parked();
cx.dispatch_action(RemoveSelected);
cx.run_until_parked();
picker.read_with(cx, |picker, _| {
let expected = std::iter::once(second_worst_match.clone())
.chain(
unranked_names
.iter()
.filter(|name| *name != &second_worst_match)
.cloned(),
)
.collect::<Vec<_>>();
assert_eq!(
picker
.delegate
.matches
.iter()
.map(|matching_command| matching_command.string.to_string())
.collect::<Vec<_>>(),
expected
);
});
let remaining_usage = db.list_commands_used().unwrap();
picker.update_in(cx, |picker, window, cx| {
picker.delegate.set_selected_index(1, window, cx);
});
cx.dispatch_action(RemoveSelected);
assert_eq!(db.list_commands_used().unwrap(), remaining_usage);
}
#[gpui::test]
async fn test_intercepted_commands_are_not_history(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |workspace, _| workspace.workspace().clone());
let picker = open_palette_with_history(&workspace, &[], cx);
for (exclusive, expected) in [
(
false,
vec![("intercepted", false), ("older", true), ("unused", false)],
),
(true, vec![("intercepted", false)]),
] {
picker.update(cx, |picker, cx| {
let commands = [
("recent", Toggle.boxed_clone()),
("older", menu::Confirm.boxed_clone()),
("unused", menu::Cancel.boxed_clone()),
]
.into_iter()
.enumerate()
.map(|(index, (name, action))| Command {
name: SharedString::from(name),
action,
usage: (index < 2).then_some(CommandUsage {
last_invoked: 0,
invocations: 1,
}),
})
.collect::<Vec<_>>();
let matches = commands
.iter()
.enumerate()
.map(|(candidate_id, command)| StringMatch {
candidate_id,
string: command.name.clone(),
positions: Vec::new(),
score: 0.0,
})
.collect::<Vec<_>>();
let intercept_result = CommandInterceptResult {
results: vec![CommandInterceptItem {
action: Toggle.boxed_clone(),
string: "intercepted".to_string(),
positions: Vec::new(),
}],
exclusive,
};
picker.delegate.matches_updated(
String::new(),
commands,
matches,
intercept_result,
cx,
);
assert_eq!(
picker
.delegate
.matches
.iter()
.map(|matching_command| {
let command = &picker.delegate.commands[matching_command.candidate_id];
(command.name.as_str(), command.usage.is_some())
})
.collect::<Vec<_>>(),
expected,
);
});
}
}
#[gpui::test]
@@ -1103,6 +1446,7 @@ mod tests {
cx.simulate_input("definitely-no-command-should-match-this");
cx.background_executor.run_until_parked();
cx.dispatch_action(RemoveSelected);
picker.read_with(cx, |picker, _cx| {
assert!(picker.delegate.matches.is_empty());
@@ -1240,7 +1584,7 @@ mod tests {
history: &[&str],
cx: &mut VisualTestContext,
) -> Entity<Picker<CommandPaletteDelegate>> {
cx.simulate_keystrokes("cmd-shift-p");
cx.dispatch_action(Toggle);
cx.run_until_parked();
let palette = workspace.update(cx, |workspace, cx| {
@@ -0,0 +1,18 @@
use settings::Settings;
#[derive(Clone, Debug, PartialEq)]
pub struct CommandPaletteSettings {
pub use_command_history: bool,
}
impl Settings for CommandPaletteSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
Self {
use_command_history: content
.command_palette
.as_ref()
.and_then(|settings| settings.use_command_history)
.expect("missing default for command_palette.use_command_history"),
}
}
}
+33
View File
@@ -86,6 +86,12 @@ impl CommandPaletteDB {
.await
}
query! {
pub async fn delete_command_history(command_name: String) -> Result<()> {
DELETE FROM command_invocations WHERE command_name = (?)
}
}
#[cfg(test)]
query! {
pub(crate) fn get_last_invoked(command: &str) -> Result<Option<SerializedCommandInvocation>> {
@@ -233,6 +239,33 @@ mod tests {
assert_eq!(commands.as_slice()[1].invocations, 1);
}
#[gpui::test]
async fn test_deletes_all_history_for_one_command() {
let db = CommandPaletteDB::open_test_db("test_deletes_all_history_for_one_command").await;
db.write_command_invocation("editor: backspace", "back")
.await
.unwrap();
db.write_command_invocation("editor: backspace", "backspace")
.await
.unwrap();
db.write_command_invocation("go to line: toggle", "line")
.await
.unwrap();
let remaining_usage = db.get_command_usage("go to line: toggle").unwrap().unwrap();
for _ in 0..2 {
db.delete_command_history("editor: backspace".to_string())
.await
.unwrap();
assert_eq!(
db.list_commands_used().unwrap(),
vec![remaining_usage.clone()]
);
assert_eq!(db.list_recent_queries().unwrap(), vec!["line"]);
}
}
#[gpui::test]
async fn test_handles_max_invocation_entries() {
let db = CommandPaletteDB::open_test_db("test_handles_max_invocation_entries").await;
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[lib]
path = "src/command_palette_hooks.rs"
doctest = false
test = false
[dependencies]
collections.workspace = true
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/component.rs"
test = false
[dependencies]
collections.workspace = true
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/component_preview.rs"
test = false
doctest = false
[features]
default = []
@@ -44,7 +44,7 @@ fn main() {
ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client");
cx.set_http_client(Arc::new(http_client));
let fs = Arc::new(RealFs::new(None, cx.background_executor().clone()));
let fs = RealFs::new(None, cx.background_executor().clone());
<dyn fs::Fs>::set_global(fs.clone(), cx);
settings::init(cx);
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/context_server.rs"
doctest = false
[features]
test-support = ["gpui/test-support"]
@@ -246,7 +246,7 @@ mod tests {
use lsp::Uri;
use project::Project;
use serde_json::json;
use settings::{AllLanguageSettingsContent, DelayMs, SettingsStore};
use settings::{AllLanguageSettingsContent, DelayMs, SettingsStore, SplicingVec};
use std::future::Future;
use util::{
path,
@@ -979,7 +979,7 @@ mod tests {
settings
.edit_predictions
.get_or_insert(Default::default())
.disabled_globs = Some(vec![".env*".to_string()]);
.disabled_globs = Some(SplicingVec::from(vec![".env*".to_string()]));
});
let (copilot, copilot_lsp) = Copilot::fake(cx);
+10 -2
View File
@@ -279,8 +279,14 @@ impl Model {
self.capabilities.limits.max_context_window_tokens as u64
}
pub fn max_output_tokens(&self) -> usize {
self.capabilities.limits.max_output_tokens
pub fn max_prompt_tokens(&self) -> Option<u64> {
let limit = self.capabilities.limits.max_prompt_tokens;
(limit > 0).then_some(limit)
}
pub fn max_output_tokens(&self) -> Option<u64> {
let limit = self.capabilities.limits.max_output_tokens as u64;
(limit > 0).then_some(limit)
}
pub fn supports_tools(&self) -> bool {
@@ -356,6 +362,8 @@ pub struct Request {
pub temperature: f32,
pub model: String,
pub messages: Vec<ChatMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+162 -7
View File
@@ -130,6 +130,16 @@ impl LanguageModel for CopilotChatLanguageModel {
self.model.max_token_count()
}
fn max_input_tokens(&self) -> u64 {
self.model
.max_prompt_tokens()
.unwrap_or_else(|| self.model.max_token_count())
}
fn max_output_tokens(&self) -> Option<u64> {
self.model.max_output_tokens()
}
fn stream_completion(
&self,
request: LanguageModelRequest,
@@ -161,6 +171,12 @@ impl LanguageModel for CopilotChatLanguageModel {
let copilot_chat = self.copilot_chat.clone();
let request_limiter = self.request_limiter.clone();
let future = cx.spawn(async move |cx| {
let max_output_tokens = model
.max_output_tokens()
.or(request.max_output_tokens)
.ok_or_else(|| {
anyhow!("Copilot did not provide an output limit for this model")
})?;
let effort = request
.thinking_effort
.as_ref()
@@ -170,7 +186,7 @@ impl LanguageModel for CopilotChatLanguageModel {
request,
model.id().to_string(),
0.0,
model.max_output_tokens() as u64,
max_output_tokens,
if model.supports_adaptive_thinking() {
AnthropicModelMode::Thinking {
budget_tokens: None,
@@ -180,7 +196,7 @@ impl LanguageModel for CopilotChatLanguageModel {
budget_tokens: compute_thinking_budget(
model.min_thinking_budget(),
model.max_thinking_budget(),
model.max_output_tokens() as u32,
max_output_tokens.min(u32::MAX as u64) as u32,
),
}
} else {
@@ -814,6 +830,9 @@ fn into_copilot_chat(
model: &CopilotChatModel,
request: LanguageModelRequest,
) -> Result<CopilotChatRequest> {
let max_tokens = request
.max_output_tokens
.and_then(|_| request.effective_max_output_tokens(model.max_output_tokens()));
let temperature = request.temperature;
let tool_choice = request.tool_choice;
let thinking_allowed = request.thinking_allowed;
@@ -1008,6 +1027,7 @@ fn into_copilot_chat(
temperature: temperature.unwrap_or(0.1),
model: model.id().to_string(),
messages,
max_tokens,
tools,
tool_choice: tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => ToolChoice::Auto,
@@ -1015,11 +1035,13 @@ fn into_copilot_chat(
LanguageModelToolChoice::None => ToolChoice::None,
}),
thinking_budget: if thinking_allowed && model.supports_thinking() {
compute_thinking_budget(
model.min_thinking_budget(),
model.max_thinking_budget(),
model.max_output_tokens() as u32,
)
model.max_output_tokens().or(max_tokens).and_then(|limit| {
compute_thinking_budget(
model.min_thinking_budget(),
model.max_thinking_budget(),
limit.min(u32::MAX as u64) as u32,
)
})
} else {
None
},
@@ -1064,8 +1086,12 @@ fn into_copilot_responses(
) -> Result<copilot_responses::Request> {
use copilot_responses as responses;
let max_output_tokens = request
.max_output_tokens
.and_then(|_| request.effective_max_output_tokens(model.max_output_tokens()));
let LanguageModelRequest {
thread_id: _,
prompt_cache_key: _,
prompt_id: _,
intent: _,
messages,
@@ -1077,6 +1103,7 @@ fn into_copilot_responses(
thinking_effort,
speed: _,
compact_at_tokens: _,
max_output_tokens: _,
} = request;
let mut input_items: Vec<responses::ResponseInputItem> = Vec::new();
@@ -1255,6 +1282,7 @@ fn into_copilot_responses(
input: input_items,
stream: model.uses_streaming(),
temperature,
max_output_tokens,
tools: converted_tools,
tool_choice: mapped_tool_choice,
reasoning: if thinking_allowed {
@@ -1284,6 +1312,85 @@ mod tests {
use language_model::ProviderErrorCategory;
use serde_json::json;
#[gpui::test]
fn language_model_exposes_token_limits(cx: &mut gpui::TestAppContext) {
use gpui::AppContext as _;
let copilot_chat = cx.new(|cx| {
CopilotChat::new(
Arc::new(http_client::BlockedHttpClient::new()),
Arc::new(EmptyCredentialsProvider),
crate::CopilotChatConfiguration::default(),
cx,
)
});
for (limits, input, context, output) in [
(
json!({"max_context_window_tokens": 200_000, "max_prompt_tokens": 90_000, "max_output_tokens": 16_384}),
90_000,
200_000,
Some(16_384),
),
(
json!({"max_context_window_tokens": 128_000, "max_output_tokens": 4096}),
128_000,
128_000,
Some(4096),
),
(
json!({"max_context_window_tokens": 128_000, "max_prompt_tokens": 100_000}),
100_000,
128_000,
None,
),
(
json!({"max_context_window_tokens": 128_000, "max_prompt_tokens": 0, "max_output_tokens": 0}),
128_000,
128_000,
None,
),
] {
let mut value = serde_json::to_value(test_responses_model()).unwrap();
value["capabilities"]["limits"] = limits;
let descriptor = serde_json::from_value(value).unwrap();
let model = create_language_model(descriptor, copilot_chat.clone());
assert_eq!(model.max_token_count(), context);
assert_eq!(model.max_input_tokens(), input);
assert_eq!(model.max_total_tokens(), Some(context));
assert_eq!(model.max_output_tokens(), output);
}
}
struct EmptyCredentialsProvider;
impl credentials_provider::CredentialsProvider for EmptyCredentialsProvider {
fn read_credentials<'a>(
&'a self,
_url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<Option<(String, Vec<u8>)>>> + 'a>> {
Box::pin(async { Ok(None) })
}
fn write_credentials<'a>(
&'a self,
_url: &'a str,
_username: &'a str,
_password: &'a [u8],
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
Box::pin(async { Ok(()) })
}
fn delete_credentials<'a>(
&'a self,
_url: &'a str,
_cx: &'a AsyncApp,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
Box::pin(async { Ok(()) })
}
}
fn map_events(events: Vec<responses::StreamEvent>) -> Vec<LanguageModelCompletionEvent> {
futures::executor::block_on(async {
CopilotResponsesEventMapper::new()
@@ -1523,6 +1630,54 @@ mod tests {
);
}
#[test]
fn request_output_limits_reach_copilot_payloads() -> Result<()> {
let model = test_responses_model();
for (limit, expected) in [
(None, None),
(Some(1024), Some(1024)),
(Some(u64::MAX), Some(4096)),
] {
let request = LanguageModelRequest {
max_output_tokens: limit,
..Default::default()
};
let responses = serde_json::to_value(into_copilot_responses(&model, request.clone())?)?;
let chat = serde_json::to_value(into_copilot_chat(&model, request)?)?;
assert_eq!(
responses.get("max_output_tokens").cloned(),
expected.map(|value| json!(value))
);
assert_eq!(
chat.get("max_tokens").cloned(),
expected.map(|value| json!(value))
);
}
let mut value = serde_json::to_value(test_responses_model())?;
value["capabilities"]["limits"]
.as_object_mut()
.unwrap()
.remove("max_output_tokens");
let model = serde_json::from_value(value)?;
for limit in [None, Some(1024)] {
let request = LanguageModelRequest {
max_output_tokens: limit,
..Default::default()
};
let responses = serde_json::to_value(into_copilot_responses(&model, request.clone())?)?;
let chat = serde_json::to_value(into_copilot_chat(&model, request)?)?;
assert_eq!(
responses.get("max_output_tokens").cloned(),
limit.map(|value| json!(value))
);
assert_eq!(
chat.get("max_tokens").cloned(),
limit.map(|value| json!(value))
);
}
Ok(())
}
#[test]
fn into_copilot_responses_replays_reasoning_details() {
let model = test_responses_model();
+2
View File
@@ -16,6 +16,8 @@ pub struct Request {
pub stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolDefinition>,
#[serde(skip_serializing_if = "Option::is_none")]
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[lib]
path = "src/copilot_ui.rs"
doctest = false
test = false
[features]
default = []
+1
View File
@@ -30,3 +30,4 @@ workspace = true
[lib]
path = "src/crashes.rs"
doctest = false
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/credentials_provider.rs"
test = false
doctest = false
[dependencies]
anyhow.workspace = true
@@ -21,3 +21,5 @@ workspace = true
[lib]
path = "src/debug_adapter_extension.rs"
test = false
doctest = false
@@ -889,13 +889,30 @@ impl VariableList {
return;
};
let variable_value = match &entry.entry {
DapEntry::Variable(dap) => dap.value.clone(),
DapEntry::Watcher(watcher) => watcher.value.to_string(),
match &entry.entry {
DapEntry::Variable(dap) => {
let fallback_value = dap.value.clone();
let expression = dap
.evaluate_name
.clone()
.unwrap_or_else(|| dap.name.clone());
let frame_id = self.selected_stack_frame_id;
let task = self.session.update(cx, |session, cx| {
session.evaluate_variable_value(expression, frame_id, cx)
});
cx.spawn(async move |_, cx| {
let value = task.await.unwrap_or(fallback_value);
cx.update(|cx| {
cx.write_to_clipboard(ClipboardItem::new_string(value));
});
})
.detach();
}
DapEntry::Watcher(watcher) => {
cx.write_to_clipboard(ClipboardItem::new_string(watcher.value.to_string()));
}
DapEntry::Scope(_) => return,
};
cx.write_to_clipboard(ClipboardItem::new_string(variable_value));
}
}
fn edit_variable(&mut self, _: &EditVariable, window: &mut Window, cx: &mut Context<Self>) {
@@ -2592,3 +2592,58 @@ async fn test_refresh_watchers(executor: BackgroundExecutor, cx: &mut TestAppCon
assert_eq!(3, watcher.variables_reference);
});
}
#[gpui::test]
async fn test_evaluate_variable_value_uses_clipboard_context(
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
fs.insert_tree(
path!("/project"),
json!({
"index.js": "const debug = { foo: 1 };",
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let workspace = init_test_workspace(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let session = start_debug_session(&workspace, cx, |client| {
client.on_request::<Initialize, _>(move |_, _| {
Ok(dap::Capabilities {
supports_clipboard_context: Some(true),
..Default::default()
})
});
})
.unwrap();
let client = session.update(cx, |session, _| session.adapter_client().unwrap());
client.on_request::<Evaluate, _>(move |_, args| {
assert_eq!("debug", args.expression);
assert_eq!(Some(dap::EvaluateArgumentsContext::Clipboard), args.context);
Ok(dap::EvaluateResponse {
result: "full value".to_string(),
type_: None,
presentation_hint: None,
variables_reference: 0,
named_variables: None,
indexed_variables: None,
memory_reference: None,
value_location_reference: None,
})
});
let value = session
.update(cx, |session, cx| {
session.evaluate_variable_value("debug".to_string(), Some(1), cx)
})
.await;
assert_eq!(Some("full value".to_string()), value);
}
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/deepseek.rs"
test = false
doctest = false
[features]
default = []
+59 -10
View File
@@ -50,8 +50,8 @@ impl From<Role> for String {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub enum Model {
#[serde(rename = "deepseek-v4-flash")]
V4Flash,
#[serde(rename = "deepseek-flash")]
V4_1Flash,
#[serde(rename = "deepseek-v4-pro")]
#[default]
V4Pro,
@@ -67,12 +67,12 @@ pub enum Model {
impl Model {
pub fn default_fast() -> Self {
Model::V4Flash
Model::V4_1Flash
}
pub fn from_id(id: &str) -> Result<Self> {
match id {
"deepseek-v4-flash" => Ok(Self::V4Flash),
"deepseek-flash" => Ok(Self::V4_1Flash),
"deepseek-v4-pro" => Ok(Self::V4Pro),
_ => anyhow::bail!("invalid model id {id}"),
}
@@ -80,7 +80,7 @@ impl Model {
pub fn id(&self) -> &str {
match self {
Self::V4Flash => "deepseek-v4-flash",
Self::V4_1Flash => "deepseek-flash",
Self::V4Pro => "deepseek-v4-pro",
Self::Custom { name, .. } => name,
}
@@ -88,7 +88,7 @@ impl Model {
pub fn display_name(&self) -> &str {
match self {
Self::V4Flash => "DeepSeek V4 Flash",
Self::V4_1Flash => "DeepSeek V4.1 Flash",
Self::V4Pro => "DeepSeek V4 Pro",
Self::Custom {
name, display_name, ..
@@ -96,16 +96,20 @@ impl Model {
}
}
pub fn supports_images(&self) -> bool {
matches!(self, Self::V4_1Flash)
}
pub fn max_token_count(&self) -> u64 {
match self {
Self::V4Flash | Self::V4Pro => 1_000_000,
Self::V4_1Flash | Self::V4Pro => 1_000_000,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
pub fn max_output_tokens(&self) -> Option<u64> {
match self {
Self::V4Flash | Self::V4Pro => Some(384_000),
Self::V4_1Flash | Self::V4Pro => Some(384_000),
Self::Custom {
max_output_tokens, ..
} => *max_output_tokens,
@@ -195,17 +199,62 @@ pub enum RequestMessage {
reasoning_content: Option<String>,
},
User {
content: String,
content: MessageContent,
},
System {
content: String,
},
Tool {
content: String,
content: MessageContent,
tool_call_id: String,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
#[serde(untagged)]
pub enum MessageContent {
Plain(String),
Multipart(Vec<MessagePart>),
}
impl MessageContent {
pub fn push_part(&mut self, part: MessagePart) {
match self {
MessageContent::Plain(text) => {
let text = std::mem::take(text);
*self = MessageContent::Multipart(vec![MessagePart::Text { text }, part]);
}
MessageContent::Multipart(parts) => parts.push(part),
}
}
}
impl From<Vec<MessagePart>> for MessageContent {
fn from(mut parts: Vec<MessagePart>) -> Self {
if let [MessagePart::Text { text }] = parts.as_mut_slice() {
MessageContent::Plain(std::mem::take(text))
} else {
MessageContent::Multipart(parts)
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
#[serde(tag = "type")]
pub enum MessagePart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image_url")]
Image { image_url: ImageUrl },
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
pub struct ImageUrl {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
pub struct ToolCall {
pub id: String,
+3
View File
@@ -4,6 +4,9 @@ version = "0.1.0"
publish.workspace = true
edition.workspace = true
[lib]
doctest = false
[dependencies]
anyhow.workspace = true
async-tar.workspace = true
@@ -4,6 +4,7 @@ use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json_lenient::Value;
use util::command::Command;
use util::redact::is_valid_environment_name;
#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
#[serde(untagged)]
@@ -285,7 +286,28 @@ impl DevContainer {
))
}
pub(crate) fn validate_environment_names(&self) -> Result<(), DevContainerError> {
for (property, environment) in [
("containerEnv", self.container_env.as_ref()),
("remoteEnv", self.remote_env.as_ref()),
] {
if environment.is_some_and(|environment| {
environment
.keys()
.any(|name| !is_valid_environment_name(name))
}) {
return Err(DevContainerError::DevContainerValidationFailed(format!(
"{property} contains an invalid environment variable name"
)));
}
}
Ok(())
}
pub(crate) fn validate_devcontainer_contents(&self) -> Result<(), DevContainerError> {
self.validate_environment_names()?;
match self.build_type() {
DevContainerBuildType::Image(_) => Ok(()),
DevContainerBuildType::Dockerfile(_) => {
@@ -1718,6 +1740,37 @@ mod test {
);
}
#[test]
fn should_reject_invalid_environment_names() {
for (json, property) in [
(
r#"{"image":"ubuntu","containerEnv":{"":"secret"}}"#,
"containerEnv",
),
(
r#"{"image":"ubuntu","containerEnv":{"NAME=VALUE":"secret"}}"#,
"containerEnv",
),
(
r#"{"image":"ubuntu","remoteEnv":{"LINE\nBREAK":"secret"}}"#,
"remoteEnv",
),
(
r#"{"image":"ubuntu","remoteEnv":{"NAME\u0000NUL":"secret"}}"#,
"remoteEnv",
),
] {
let devcontainer = deserialize_devcontainer_json(json).expect("valid JSON");
assert_eq!(
devcontainer.validate_environment_names(),
Err(DevContainerError::DevContainerValidationFailed(format!(
"{property} contains an invalid environment variable name"
)))
);
}
}
#[test]
fn should_pass_validation_with_workspace_folder_for_docker_compose() {
let given_image_container_json = r#"
@@ -2731,6 +2731,9 @@ pub(crate) async fn spawn_dev_container(
.await?;
devcontainer_manifest.parse_nonremote_vars()?;
devcontainer_manifest
.dev_container()
.validate_environment_names()?;
log::debug!("Checking for existing container");
if let Some(devcontainer) = devcontainer_manifest
@@ -64,11 +64,9 @@ impl DiagnosticRenderer {
} else {
append_source_and_code(&mut markdown, entry.diagnostic);
if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 {
markdown.push_str(&format!(
" ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))"
));
}
markdown.push_str(&format!(
" ([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_ix}))"
));
results.push(DiagnosticBlock {
initial_range: entry.range.clone(),
severity: entry.diagnostic.severity,
+65 -9
View File
@@ -11,7 +11,7 @@ use editor::{
};
use gpui::{TestAppContext, VisualTestContext};
use indoc::indoc;
use language::{DiagnosticSourceKind, Rope};
use language::{BufferId, Diagnostic, DiagnosticEntryRef, DiagnosticSourceKind, Rope};
use lsp::LanguageServerId;
use pretty_assertions::assert_eq;
use project::{
@@ -142,7 +142,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
§ move occurs because `y` has type `Vec<char>`, which does not implement
§ the `Copy` trait (back)
a(x); § value moved here (back)
b(y); § value moved here
b(y); § value moved here (back)
// comment 1
// comment 2
c(y);
@@ -222,7 +222,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
§ move occurs because `y` has type `Vec<char>`, which does not implement
§ the `Copy` trait (back)
a(x); § value moved here (back)
b(y); § value moved here
b(y); § value moved here (back)
// comment 1
// comment 2
c(y);
@@ -313,7 +313,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
§ move occurs because `y` has type `Vec<char>`, which does not implement
§ the `Copy` trait (back)
a(x); § value moved here (back)
b(y); § value moved here
b(y); § value moved here (back)
// comment 1
// comment 2
c(y);
@@ -433,7 +433,7 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) {
indoc::indoc! {
"§ main.js
§ -----
function test() { § method `test` defined here
function test() { § method `test` defined here (back)
return 1
};
@@ -1347,6 +1347,62 @@ async fn test_diagnostics_with_links(cx: &mut TestAppContext) {
cx.update_editor(|editor, _, _| assert!(editor.hover_state.diagnostic_popover.is_some()))
}
#[gpui::test]
fn test_nearby_related_diagnostic_links_back_to_primary(cx: &mut TestAppContext) {
init_test(cx);
let buffer_id = BufferId::new(1).expect("test buffer ID should be nonzero");
let group_id = 1;
let diagnostic_group = [
DiagnosticEntryRef {
range: text::Point::new(10, 4)..text::Point::new(10, 16),
diagnostic: &Diagnostic {
message: "the trait bound `(): HasTest` is not satisfied".into(),
is_primary: true,
group_id,
..Default::default()
},
},
DiagnosticEntryRef {
range: text::Point::new(13, 0)..text::Point::new(13, 22),
diagnostic: &Diagnostic {
message: "required by a bound in `Test::TestAssoc`".into(),
group_id,
..Default::default()
},
},
];
let primary_index = diagnostic_group
.iter()
.position(|entry| entry.diagnostic.is_primary)
.expect("test diagnostic group should contain a primary diagnostic");
let expected_back_link =
format!("([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_index}))");
cx.update(|cx| {
let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
diagnostic_group.to_vec(),
buffer_id,
None,
None,
cx,
);
let [_primary_block, related_block] = blocks.as_slice() else {
panic!("expected one primary and one related diagnostic block");
};
let markdown = related_block.markdown.read(cx).source();
assert!(
markdown.contains(&expected_back_link),
"related diagnostic should link back to the primary diagnostic, got: {}",
markdown
);
});
}
#[gpui::test]
async fn test_markup_content_diagnostic_messages_render_as_markdown(cx: &mut TestAppContext) {
init_test(cx);
@@ -2085,9 +2141,9 @@ async fn test_buffer_diagnostics(cx: &mut TestAppContext) {
§ the `Copy` trait (back)
let y = vec![];
§ move occurs because `y` has type `Vec<char>`, which does not implement
§ the `Copy` trait
a(x); § value moved here
b(y); § value moved here
§ the `Copy` trait (back)
a(x); § value moved here (back)
b(y); § value moved here (back)
c(y);
§ use of moved value
§ value used here after move
@@ -2221,7 +2277,7 @@ async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) {
§ move occurs because `x` has type `Vec<char>`, which does not implement
§ the `Copy` trait (back)
let y = vec![];
a(x); § value moved here
a(x); § value moved here (back)
b(y);
c(y);
d(x);
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/edit_prediction.rs"
doctest = false
[features]
cli-support = []
@@ -17,7 +17,7 @@ use cloud_llm_client::{
};
use db::AppDatabase;
use edit_prediction_types::EditPredictionRequestTrigger;
use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _, FeatureFlagsSettings};
use feature_flags::FeatureFlagsSettings;
use futures::{
AsyncReadExt, FutureExt, StreamExt,
channel::{mpsc, oneshot},
+1 -4
View File
@@ -66,10 +66,7 @@ pub fn init(cx: &mut App) -> EpAppState {
cx.set_global(app_db);
let git_binary_path = None;
let fs = Arc::new(RealFs::new(
git_binary_path,
cx.background_executor().clone(),
));
let fs = RealFs::new(git_binary_path, cx.background_executor().clone());
let mut languages = LanguageRegistry::new(cx.background_executor().clone());
languages.set_language_server_download_dir(paths::languages_dir().clone());
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/edit_prediction_context.rs"
doctest = false
[dependencies]
anyhow.workspace = true
@@ -10,6 +10,7 @@ workspace = true
[lib]
path = "src/edit_prediction_metrics.rs"
doctest = false
[features]
tree-sitter = ["dep:tree-sitter"]
+2
View File
@@ -10,6 +10,8 @@ workspace = true
[lib]
path = "src/edit_prediction_types.rs"
test = false
doctest = false
[dependencies]
client.workspace = true
-1
View File
@@ -36,7 +36,6 @@ menu.workspace = true
multi_buffer.workspace = true
paths.workspace = true
project.workspace = true
regex.workspace = true
settings.workspace = true
telemetry.workspace = true
text.workspace = true
@@ -23,13 +23,11 @@ use language::{
},
};
use project::{DisableAiSettings, Project};
use regex::Regex;
use settings::{Settings, SettingsStore, update_settings_file};
use std::{
rc::Rc,
sync::{Arc, LazyLock},
time::Duration,
use settings::{
Settings, SettingsContent, SettingsStore, SplicingVec, find_value_range_in_json_text,
update_settings_file,
};
use std::{ops::Range, rc::Rc, sync::Arc, time::Duration};
use ui::{
Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape,
Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
@@ -822,7 +820,12 @@ impl EditPredictionButton {
ContextMenuEntry::new("Subtle")
.toggleable(IconPosition::Start, subtle_mode)
.documentation_aside(DocumentationSide::Left, move |_| {
Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
Label::new(concat!(
"Display predictions inline only when holding a modifier key (",
ui::alt_key_name!(),
" by default)."
))
.into_any_element()
})
.handler({
let fs = fs.clone();
@@ -1397,6 +1400,25 @@ impl StatusItemView for EditPredictionButton {
}
}
fn initialize_disabled_globs_setting(file: &mut SettingsContent) {
file.project
.all_languages
.edit_predictions
.get_or_insert_with(Default::default)
.disabled_globs
.get_or_insert_with(|| SplicingVec::from(vec![SplicingVec::REST.to_string()]));
}
fn disabled_globs_content_range(text: &str) -> Option<Range<usize>> {
let array_range = find_value_range_in_json_text(text, &["edit_predictions", "disabled_globs"])?;
let content = text
.get(array_range.clone())?
.strip_prefix('[')?
.strip_suffix(']')?;
let start = array_range.start + 1 + (content.len() - content.trim_start().len());
Some(start..start + content.trim().len())
}
async fn open_disabled_globs_setting_in_editor(
workspace: WeakEntity<Workspace>,
cx: &mut AsyncWindowContext,
@@ -1418,16 +1440,8 @@ async fn open_disabled_globs_setting_in_editor(
let settings = cx.global::<SettingsStore>();
// Ensure that we always have "edit_predictions { "disabled_globs": [] }"
let Some(edits) = settings
.edits_for_update(&text, |file| {
file.project
.all_languages
.edit_predictions
.get_or_insert_with(Default::default)
.disabled_globs
.get_or_insert_with(Vec::new);
})
.edits_for_update(&text, initialize_disabled_globs_setting)
.log_err()
else {
return;
@@ -1444,16 +1458,7 @@ async fn open_disabled_globs_setting_in_editor(
let text = item.buffer().read(cx).snapshot(cx).text();
static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
});
// Only capture [...]
let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
captures
.name("content")
.map(|inner_match| inner_match.start()..inner_match.end())
});
if let Some(range) = range {
if let Some(range) = disabled_globs_content_range(&text) {
let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
item.change_selections(
SelectionEffects::scroll(Autoscroll::newest()),
@@ -1681,7 +1686,118 @@ fn copilot_settings_url(enterprise_uri: Option<&str>) -> Arc<str> {
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context as _;
use gpui::TestAppContext;
use settings::RootUserSettings;
#[test]
fn test_disabled_globs_selection_and_replacement() -> Result<()> {
for contents in [
r#""[.][.][.]""#,
r#""**/[ab]/**", "...""#,
r#""escaped\"]", "backslash\\", "...""#,
r#""escaped\\]", "line\nbreak]", "unicode\u005d""#,
"\n \"é[ab]\",\n \"...\"\n ",
"// Ignore filenames containing a \" character\n \"**/private/**\"\n",
"/* Ignore \" and ] */\n \"**/private/**\"\n",
"\"**/private/**\", // Keep \" and ]\n \"...\"\n",
r#""**/build/**", "...""#,
"",
"\n ",
] {
let mut text = format!(
r#"// "disabled_globs": ["commented/**"]
{{"languages":{{"Rust":{{"disabled_globs":["nested/**"]}}}},
/* "disabled_globs": ["commented/**"] */
"edit_predictions":{{"disabled_globs":[{contents}],"mode":"subtle"}}}}"#
);
let mut expected = SettingsContent::parse_json_with_comments(&text)?;
let range = disabled_globs_content_range(&text).context("disabled globs selection")?;
assert_eq!(text.get(range.clone()), Some(contents.trim()));
text.replace_range(range, r#""**/replacement/**", "...""#);
expected
.project
.all_languages
.edit_predictions
.as_mut()
.context("edit prediction settings")?
.disabled_globs = Some(SplicingVec::from(vec![
"**/replacement/**".to_string(),
SplicingVec::REST.to_string(),
]));
assert_eq!(SettingsContent::parse_json_with_comments(&text)?, expected);
}
Ok(())
}
#[gpui::test]
fn test_initialize_disabled_globs_setting(cx: &mut App) {
let store = SettingsStore::new(cx, &settings::default_settings());
let cases: &[(&str, &[&str])] = &[
("", &[SplicingVec::REST]),
(r#"{}"#, &[SplicingVec::REST]),
(r#"{"edit_predictions":{}}"#, &[SplicingVec::REST]),
(
r#"{"edit_predictions":{"mode":"subtle"}}"#,
&[SplicingVec::REST],
),
(r#"{"edit_predictions":{"disabled_globs":[]}}"#, &[]),
(
r#"{"edit_predictions":{"disabled_globs":["**/build/**"]}}"#,
&["**/build/**"],
),
(
r#"{"edit_predictions":{"disabled_globs":["...","**/build/**"]}}"#,
&[SplicingVec::REST, "**/build/**"],
),
(
r#"{"edit_predictions":{"disabled_globs":["[.][.][.]"]}}"#,
&["[.][.][.]"],
),
];
for &(content, expected_globs) in cases {
let original = if content.is_empty() { "{}" } else { content };
let mut expected_content = SettingsContent::parse_json_with_comments(original)
.expect("settings content parses");
let original_globs = &mut expected_content
.project
.all_languages
.edit_predictions
.get_or_insert_with(Default::default)
.disabled_globs;
let already_configured = original_globs.is_some();
*original_globs = Some(SplicingVec::from(
expected_globs
.iter()
.map(|glob| glob.to_string())
.collect::<Vec<_>>(),
));
let edits = store
.edits_for_update(content, initialize_disabled_globs_setting)
.expect("settings edits are generated");
assert_eq!(edits.is_empty(), already_configured, "{content}");
let mut updated = content.to_string();
for (range, replacement) in edits {
updated.replace_range(range, &replacement);
}
assert_eq!(
SettingsContent::parse_json_with_comments(&updated)
.expect("updated settings parse"),
expected_content,
"{content}",
);
assert!(
store
.edits_for_update(&updated, initialize_disabled_globs_setting)
.expect("repeated settings edits are generated")
.is_empty(),
"{content}",
);
}
}
#[gpui::test]
async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
+19 -1
View File
@@ -140,7 +140,7 @@ pub struct ConfirmCodeAction {
}
/// Toggles comment markers for the selected lines.
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)]
#[action(namespace = editor)]
#[serde(deny_unknown_fields)]
pub struct ToggleComments {
@@ -148,6 +148,22 @@ pub struct ToggleComments {
pub advance_downwards: bool,
#[serde(default)]
pub ignore_indent: bool,
/// Whether to add comment markers to blank lines inside a multi-line
/// selection. A line of only whitespace counts as blank. Defaults to true.
#[serde(default = "default_true")]
pub comment_empty_lines: bool,
}
// `Default` is written out rather than derived because `comment_empty_lines`
// defaults to true.
impl Default for ToggleComments {
fn default() -> Self {
Self {
advance_downwards: false,
ignore_indent: false,
comment_empty_lines: true,
}
}
}
/// Toggles block comment markers for the selected text.
@@ -938,6 +954,8 @@ actions!(
UnwrapSyntaxNode,
/// Wraps selections in tag specified by language.
WrapSelectionsInTag,
/// Wraps selections in an expanded Emmet abbreviation.
WrapWithAbbreviation,
/// Aligns selections from different rows into the same column
AlignSelections,
/// Saves the current location to navigation history.
+4 -1
View File
@@ -51,6 +51,7 @@ pub fn switch_source_header(
let request = proto::LspExtSwitchSourceHeader {
project_id,
buffer_id: buffer_id.to_proto(),
server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -63,7 +64,9 @@ pub fn switch_source_header(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
project::lsp_store::lsp_ext_command::SwitchSourceHeader,
project::lsp_store::lsp_ext_command::SwitchSourceHeader {
server_id: server_to_query,
},
cx,
)
})
-1
View File
@@ -1172,7 +1172,6 @@ impl CompletionsMenu {
.start_slot::<AnyElement>(start_slot)
.child(
h_flex()
.min_w_0()
.flex_grow_1()
.when(left_aligned_suffix, |this| this.justify_start())
.when(right_aligned_suffix, |this| {
Loaded 100 of 775 files, more files were not shown because too many files have changed in this diff. Show more