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:
commit
f1ba3ea49f
775 files changed
+86177
-17521
No files matched your search
@@ -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
|
||||
|
||||
+3014
-325
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
[lib]
|
||||
path = "src/acp_tools.rs"
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[features]
|
||||
test-support = ["workspace/test-support"]
|
||||
|
||||
@@ -7,6 +7,7 @@ license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
path = "src/action_log.rs"
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+585
-311
File diff suppressed because it is too large.
Load diff
+574
-227
File diff suppressed because it is too large.
Load diff
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -81,6 +81,7 @@ impl AgentTool for RenameTool {
|
||||
resolved.buffer.clone(),
|
||||
resolved.position,
|
||||
input.new_name.clone(),
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
+242
-253
@@ -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;
|
||||
|
||||
@@ -361,6 +361,7 @@ mod tests {
|
||||
version: SharedString::from("1.0.0"),
|
||||
repository: None,
|
||||
website: None,
|
||||
license_url: None,
|
||||
icon_path: None,
|
||||
},
|
||||
package: id,
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/agent_settings.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "agent_skills.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/ai_onboarding.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/anthropic.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/askpass.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -7,6 +7,8 @@ license = "GPL-3.0-or-later"
|
||||
|
||||
[lib]
|
||||
path = "src/assets.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/auto_update_ui.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
agent_skills.workspace = true
|
||||
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/aws_http_client.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/bedrock.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -8,6 +8,7 @@ license = "GPL-3.0-or-later"
|
||||
[lib]
|
||||
path = "src/benchmarks.rs"
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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);
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[lib]
|
||||
path = "src/breadcrumbs.rs"
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/buffer_diff.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = ["settings"]
|
||||
|
||||
@@ -132,6 +132,7 @@ impl RefreshLlmTokenListener {
|
||||
MessageToClient::UserUpdated => {
|
||||
this.update(cx, |this, cx| this.refresh(TokenRefreshMode::Refresh, cx));
|
||||
}
|
||||
MessageToClient::NotificationsUpdated => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-21
@@ -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(())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[lib]
|
||||
path = "src/command_palette_hooks.rs"
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
collections.workspace = true
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/component.rs"
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
collections.workspace = true
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[lib]
|
||||
path = "src/copilot_ui.rs"
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -30,3 +30,4 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/crashes.rs"
|
||||
doctest = false
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/deepseek.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/edit_prediction_types.rs"
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
client.workspace = true
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
Reference in new issue
Block a user