Files
zed/crates/settings_content/src/agent.rs
T
Jack Walters d9abcde22a Add a setting for Threads Sidebar auto-open (#64259)
Closes https://github.com/zed-industries/zed/discussions/64266

Opening a folder in an existing window force-opens the Threads Sidebar,
and there is no way to prevent it. `open_paths` called
`MultiWorkspace::open_sidebar` unconditionally on the reused window, so
the sidebar reopened on every such open regardless of whether the user
had closed it.

This adds `agent.threads_sidebar_auto_open`, defaulting to `true` so
current behavior is unchanged. With `false`, the folder still opens in
the existing window, but the sidebar stays closed.

This path is reached when `default_open_behavior` or
`cli_default_open_behavior` is set to `existing_window`.

`open_sidebar` was doing double duty: besides showing the sidebar, it
pinned the workspace being navigated away from so it survives the
switch. When the setting is disabled, `retain_active_workspace` is
called instead, so only sidebar visibility changes.


https://github.com/user-attachments/assets/fc299df0-27e2-4ac6-98cb-d817d5f71d0e

## Tests

Two tests in `multi_workspace_tests.rs` cover both branches: the sidebar
opens by default, and stays closed when the setting is disabled while
the folder still opens in the same window.

Release Notes:

- Added `agent.threads_sidebar_auto_open` to control whether opening a
folder in an existing window also opens the Threads Sidebar
2026-09-16 16:03:50 +00:00

1448 lines
52 KiB
Rust

use collections::{HashMap, IndexMap};
use schemars::{JsonSchema, json_schema};
use serde::{Deserialize, Serialize};
use settings_macros::{MergeFrom, with_fallible_options};
use std::sync::Arc;
use std::{
borrow::Cow,
path::{Path, PathBuf},
};
use crate::ExtendingVec;
use crate::DockPosition;
/// Where to position the threads sidebar.
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum SidebarDockPosition {
/// Always show the sidebar on the left side.
#[default]
Left,
/// Always show the sidebar on the right side.
Right,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum SidebarSide {
#[default]
Left,
Right,
}
/// How thinking blocks should be displayed by default in the agent panel.
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingBlockDisplay {
/// Thinking blocks fully expand during streaming, then auto-collapse
/// when the model finishes thinking. Users can re-expand after collapse.
#[default]
Auto,
/// Thinking blocks auto-expand with a height constraint during streaming,
/// then remain in their constrained state when complete. Users can click
/// to fully expand or collapse.
Preview,
/// Thinking blocks are always fully expanded by default (no height constraint).
AlwaysExpanded,
/// Thinking blocks are always collapsed by default.
AlwaysCollapsed,
}
/// Threshold at which agent auto-compaction runs. See
/// [`AutoCompactSettingsContent::threshold`] for the accepted formats.
///
/// The canonical textual form is stored verbatim so it can round-trip through
/// the settings UI; it is serialized back as a JSON string for percentages and
/// as a JSON integer for token counts.
#[derive(Clone, Debug, PartialEq, Eq, MergeFrom)]
pub struct AutoCompactThreshold(pub String);
impl From<String> for AutoCompactThreshold {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<AutoCompactThreshold> for String {
fn from(value: AutoCompactThreshold) -> Self {
value.0
}
}
impl AsRef<str> for AutoCompactThreshold {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for AutoCompactThreshold {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if self.0.ends_with('%') {
serializer.serialize_str(&self.0)
} else if let Ok(tokens) = self.0.parse::<i64>() {
serializer.serialize_i64(tokens)
} else {
serializer.serialize_str(&self.0)
}
}
}
impl<'de> Deserialize<'de> for AutoCompactThreshold {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct ThresholdVisitor;
impl serde::de::Visitor<'_> for ThresholdVisitor {
type Value = AutoCompactThreshold;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter
.write_str("a percentage string like \"90%\" or an integer number of tokens")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(AutoCompactThreshold(value.to_owned()))
}
fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
Ok(AutoCompactThreshold(value.to_string()))
}
fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
Ok(AutoCompactThreshold(value.to_string()))
}
fn visit_f64<E: serde::de::Error>(self, value: f64) -> Result<Self::Value, E> {
Ok(AutoCompactThreshold(value.to_string()))
}
}
deserializer.deserialize_any(ThresholdVisitor)
}
}
impl JsonSchema for AutoCompactThreshold {
fn schema_name() -> Cow<'static, str> {
"AutoCompactThreshold".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
json_schema!({
"oneOf": [
{
"type": "string",
"pattern": "^\\d+(\\.\\d+)?%$"
},
{
"type": "integer"
}
]
})
}
}
#[with_fallible_options]
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
pub struct AutoCompactSettingsContent {
/// Whether to automatically compact the agent's context when it grows too
/// large, summarizing earlier messages to free up room in the model's
/// context window.
///
/// Default: true
pub enabled: Option<bool>,
/// The threshold at which auto-compaction runs. This is one of:
///
/// - A percentage string ending in `%`, e.g. `"90%"`, measured against the
/// model's context window. `"90%"` compacts once the context is 90% full.
/// - A positive integer: compaction runs once that many tokens have been
/// used. For example, `100000` compacts after 100,000 tokens are used.
/// - A negative integer: compaction runs once that many tokens remain in
/// the context window. For example, `-20000` compacts once there are
/// fewer than 20,000 tokens of headroom left in the context window.
///
/// `0` is not a valid threshold.
///
/// Default: "90%"
pub threshold: Option<AutoCompactThreshold>,
}
#[with_fallible_options]
#[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)]
pub struct AgentSettingsContent {
/// Whether the Agent is enabled.
///
/// Default: true
pub enabled: Option<bool>,
/// Whether to show the agent panel button in the status bar.
///
/// Default: true
pub button: Option<bool>,
/// Where to dock the agent panel.
///
/// Default: left (Agentic layout), right (Classic layout)
pub dock: Option<DockPosition>,
/// Whether the agent panel should use flexible (proportional) sizing when docked to the
/// left or right.
///
/// When enabled, `default_width` does not control the panel width, and resetting the panel
/// restores the default proportion.
///
/// Default: true
pub flexible: Option<bool>,
/// Where to position the sidebar holding the threads list and the agent panel.
///
/// Default: left
pub sidebar_side: Option<SidebarDockPosition>,
/// Default width in pixels for the Threads Sidebar.
///
/// Values range from 200 to 800, matching the widths the sidebar can be
/// dragged to. Values outside that range are clamped into it.
///
/// Default: 300
#[schemars(range(min = 200, max = 800))]
pub threads_sidebar_default_width: Option<crate::PixelSetting>,
/// Whether opening a folder in an existing window automatically opens the
/// Threads Sidebar.
///
/// This applies when a folder opens in an existing window instead of a new
/// one, which happens when `default_open_behavior` or
/// `cli_default_open_behavior` is set to `existing_window`. When disabled,
/// the folder still opens in that window, but the sidebar stays closed
/// until you open it.
///
/// Default: true
pub threads_sidebar_auto_open: Option<bool>,
/// Default fixed width in pixels when the agent panel is docked to the left or right and
/// `flexible` is false.
///
/// Default: 640
pub default_width: Option<crate::PixelSetting>,
/// Default height in pixels when the agent panel is docked to the bottom.
///
/// Default: 320
pub default_height: Option<crate::PixelSetting>,
/// Whether to limit the content width in the agent panel. When enabled,
/// content will be constrained to `max_content_width` and centered when
/// the panel is wider than that value, for optimal readability.
///
/// Default: true
pub limit_content_width: Option<bool>,
/// Maximum content width in pixels for the agent panel. Content will be
/// centered when the panel is wider than this value.
///
/// Default: 850
pub max_content_width: Option<crate::PixelSetting>,
/// The default model to use when creating new chats and for other features when a specific model is not specified.
pub default_model: Option<LanguageModelSelection>,
/// The model to use for subagents spawned via the `spawn_agent` tool. Defaults to the parent agent's model when not specified.
pub subagent_model: Option<LanguageModelSelection>,
/// Favorite models to show at the top of the model selector.
#[serde(default)]
pub favorite_models: Vec<LanguageModelSelection>,
/// Model to use for the inline assistant. Defaults to default_model when not specified.
pub inline_assistant_model: Option<LanguageModelSelection>,
/// Model to use for the inline assistant when streaming tools are enabled.
///
/// Default: true
pub inline_assistant_use_streaming_tools: Option<bool>,
/// Model to use for generating git commit messages. Defaults to default_model when not specified.
pub commit_message_model: Option<LanguageModelSelection>,
/// Whether to include project rules files (AGENTS.md, CLAUDE.md, .rules, etc.)
/// in the prompt when generating git commit messages.
///
/// Default: true
pub commit_message_include_project_rules: Option<bool>,
/// Custom instructions to include in the prompt when generating git commit messages.
/// Applied in addition to any project rules files (such as `.rules` or `AGENTS.md`).
pub commit_message_instructions: Option<String>,
/// Model to use for generating thread summaries. Defaults to default_model when not specified.
pub thread_summary_model: Option<LanguageModelSelection>,
/// Model to use for context compaction (`/compact` and auto-compaction).
/// Falls back to the thread's currently selected model when not specified.
/// If the configured model is unavailable (provider not registered, model
/// not found), the thread's current model is used instead.
pub compaction_model: Option<LanguageModelSelection>,
/// Additional models with which to generate alternatives when performing inline assists.
pub inline_alternatives: Option<Vec<LanguageModelSelection>>,
/// The default profile to use in the Agent.
///
/// Default: write
pub default_profile: Option<Arc<str>>,
/// The available agent profiles.
pub profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
/// Where to show a popup notification when the agent is waiting for user input.
///
/// Default: "primary_screen"
pub notify_when_agent_waiting: Option<NotifyWhenAgentWaiting>,
/// When to play a sound when the agent has either completed its response, or needs user input.
///
/// Default: never
pub play_sound_when_agent_done: Option<PlaySoundWhenAgentDone>,
/// Whether to keep the system awake while agent threads are running.
///
/// Default: true
pub prevent_idle_sleep: Option<bool>,
/// Whether to display agent edits in single-file editors in addition to the review multibuffer pane.
///
/// Default: false
pub single_file_review: Option<bool>,
/// Additional parameters for language model requests. When making a request
/// to a model, parameters will be taken from the last entry in this list
/// that matches the model's provider and name. In each entry, both provider
/// and model are optional, so that you can specify parameters for either
/// one.
///
/// Default: []
#[serde(default)]
pub model_parameters: Vec<LanguageModelParameters>,
/// Settings for automatic agent context compaction, which summarizes
/// earlier messages to free up room in the model's context window once the
/// context grows too large.
pub auto_compact: Option<AutoCompactSettingsContent>,
/// Whether to show thumb buttons for feedback in the agent panel.
///
/// Default: true
pub enable_feedback: Option<bool>,
/// Whether to have edit cards in the agent panel expanded, showing a preview of the full diff.
///
/// Default: true
pub expand_edit_card: Option<bool>,
/// Whether to have terminal cards in the agent panel expanded, showing the whole command output.
///
/// Default: true
pub expand_terminal_card: Option<bool>,
/// Command to automatically run when Zed creates a Terminal Thread shell in the agent panel.
/// The command is sent to the shell as if typed, so it is interpreted by your
/// configured shell (including on Windows and remote/WSL projects).
/// An empty string disables this behavior.
///
/// Default: ""
pub terminal_init_command: Option<String>,
/// How thinking blocks should be displayed by default in the agent panel.
///
/// Default: automatic
pub thinking_display: Option<ThinkingBlockDisplay>,
/// Whether clicking the stop button on a running terminal tool should also cancel the agent's generation.
/// Note that this only applies to the stop button, not to ctrl+c inside the terminal.
///
/// Default: true
pub cancel_generation_on_terminal_stop: Option<bool>,
/// Whether to always use cmd-enter (or ctrl-enter on Linux or Windows) to send messages in the agent panel.
///
/// Default: false
pub use_modifier_to_send: Option<bool>,
/// Minimum number of lines of height the agent message editor should have.
///
/// Default: 4
pub message_editor_min_lines: Option<usize>,
/// Whether to show turn statistics (elapsed time during generation, final turn duration).
///
/// Default: false
pub show_turn_stats: Option<bool>,
/// Whether to show the merge conflict indicator in the status bar
/// that offers to resolve conflicts using the agent.
///
/// Default: true
pub show_merge_conflict_indicator: Option<bool>,
/// Per-tool permission rules for granular control over which tool actions
/// require confirmation.
///
/// The global `default` applies when no tool-specific rules match.
/// For external agent servers (e.g. Claude Agent) that define their own
/// permission modes, "deny" and "confirm" still take precedence — the
/// external agent's permission system is only used when Zed would allow
/// the action. Per-tool regex patterns (`always_allow`, `always_deny`,
/// `always_confirm`) match against the tool's text input (command, path,
/// URL, etc.).
pub tool_permissions: Option<ToolPermissionsContent>,
/// Persistent sandbox permission grants for agent-run terminal commands.
/// These are populated when choosing "Allow always" from a sandbox
/// escalation prompt.
pub sandbox_permissions: Option<SandboxPermissionsContent>,
}
impl AgentSettingsContent {
pub fn set_dock(&mut self, dock: DockPosition) {
self.dock = Some(dock);
}
pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) {
self.sidebar_side = Some(position);
}
pub fn set_flexible_size(&mut self, flexible: bool) {
self.flexible = Some(flexible);
}
pub fn set_model(&mut self, language_model: LanguageModelSelection) {
self.default_model = Some(language_model)
}
pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
self.inline_assistant_model = Some(LanguageModelSelection {
provider: provider.into(),
model,
enable_thinking: false,
effort: None,
speed: None,
});
}
pub fn set_profile(&mut self, profile_id: Arc<str>) {
self.default_profile = Some(profile_id);
}
pub fn add_favorite_model(&mut self, model: LanguageModelSelection) {
// Note: this is intentional to not compare using `PartialEq`here.
// Full equality would treat entries that differ just in thinking/effort/speed
// as distinct and silently produce duplicates.
if !self
.favorite_models
.iter()
.any(|m| m.provider == model.provider && m.model == model.model)
{
self.favorite_models.push(model);
}
}
pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
self.favorite_models
.retain(|m| !(m.provider == model.provider && m.model == model.model));
}
pub fn update_favorite_model<F>(&mut self, provider: &str, model: &str, f: F)
where
F: FnOnce(&mut LanguageModelSelection),
{
if let Some(entry) = self
.favorite_models
.iter_mut()
.find(|m| m.provider.0 == provider && m.model == model)
{
f(entry);
}
}
pub fn set_tool_default_permission(&mut self, tool_id: &str, mode: ToolPermissionMode) {
let tool_permissions = self.tool_permissions.get_or_insert_default();
let tool_rules = tool_permissions
.tools
.entry(Arc::from(tool_id))
.or_default();
tool_rules.default = Some(mode);
}
pub fn add_tool_allow_pattern(&mut self, tool_name: &str, pattern: String) {
let tool_permissions = self.tool_permissions.get_or_insert_default();
let tool_rules = tool_permissions
.tools
.entry(Arc::from(tool_name))
.or_default();
let always_allow = tool_rules.always_allow.get_or_insert_default();
if !always_allow.0.iter().any(|r| r.pattern == pattern) {
always_allow.0.push(ToolRegexRule {
pattern,
case_sensitive: None,
});
}
}
pub fn add_tool_deny_pattern(&mut self, tool_name: &str, pattern: String) {
let tool_permissions = self.tool_permissions.get_or_insert_default();
let tool_rules = tool_permissions
.tools
.entry(Arc::from(tool_name))
.or_default();
let always_deny = tool_rules.always_deny.get_or_insert_default();
if !always_deny.0.iter().any(|r| r.pattern == pattern) {
always_deny.0.push(ToolRegexRule {
pattern,
case_sensitive: None,
});
}
}
pub fn allow_sandbox_all_hosts(&mut self) {
self.sandbox_permissions
.get_or_insert_default()
.allow_all_hosts = Some(true);
}
/// The persisted sandbox network host patterns, as written (callers own
/// parsing/validation).
pub fn sandbox_network_hosts(&self) -> &[String] {
self.sandbox_permissions
.as_ref()
.and_then(|permissions| permissions.network_hosts.as_ref())
.map(|hosts| hosts.0.as_slice())
.unwrap_or_default()
}
/// Replace the persisted sandbox network host patterns. Callers compute
/// the new list (typically the old list plus newly granted hosts, pruned
/// of entries subsumed by wildcards) rather than appending blindly.
pub fn set_sandbox_network_hosts(&mut self, hosts: Vec<String>) {
self.sandbox_permissions
.get_or_insert_default()
.network_hosts = Some(ExtendingVec(hosts));
}
pub fn allow_sandbox_fs_write_all(&mut self) {
self.sandbox_permissions
.get_or_insert_default()
.allow_fs_write_all = Some(true);
}
pub fn allow_sandbox_unsandboxed(&mut self) {
self.sandbox_permissions
.get_or_insert_default()
.allow_unsandboxed = Some(true);
}
pub fn add_sandbox_write_path(&mut self, granted: GrantedWritePathContent) {
let write_paths = &mut self
.sandbox_permissions
.get_or_insert_default()
.write_paths
.get_or_insert_default()
.0;
// Mirror `util::paths::insert_subtree`, keeping the grant set minimal,
// but compare by each entry's canonical (resolved) grant path.
let canonical = granted.canonical_or_requested().to_path_buf();
if write_paths
.iter()
.any(|existing| canonical.starts_with(existing.canonical_or_requested()))
{
return;
}
write_paths.retain(|existing| !existing.canonical_or_requested().starts_with(&canonical));
write_paths.push(granted);
}
}
#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct AgentProfileContent {
pub name: Arc<str>,
#[serde(default)]
pub tools: IndexMap<Arc<str>, bool>,
/// Whether all context servers are enabled by default.
pub enable_all_context_servers: Option<bool>,
#[serde(default)]
pub context_servers: IndexMap<Arc<str>, ContextServerPresetContent>,
/// The default language model selected when using this profile.
pub default_model: Option<LanguageModelSelection>,
}
#[with_fallible_options]
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ContextServerPresetContent {
pub tools: IndexMap<Arc<str>, bool>,
}
#[derive(
Copy,
Clone,
Default,
Debug,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
PartialEq,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum NotifyWhenAgentWaiting {
#[default]
PrimaryScreen,
AllScreens,
Never,
}
#[derive(
Copy,
Clone,
Default,
Debug,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
PartialEq,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum PlaySoundWhenAgentDone {
#[default]
Never,
WhenHidden,
Always,
}
impl PlaySoundWhenAgentDone {
pub fn should_play(&self, visible: bool) -> bool {
match self {
PlaySoundWhenAgentDone::Never => false,
PlaySoundWhenAgentDone::WhenHidden => !visible,
PlaySoundWhenAgentDone::Always => true,
}
}
}
#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct LanguageModelSelection {
pub provider: LanguageModelProviderSetting,
pub model: String,
#[serde(default)]
pub enable_thinking: bool,
pub effort: Option<String>,
pub speed: Option<language_model_core::Speed>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct LanguageModelParameters {
pub provider: Option<LanguageModelProviderSetting>,
pub model: Option<String>,
#[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
pub temperature: Option<f32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, MergeFrom)]
pub struct LanguageModelProviderSetting(pub String);
impl JsonSchema for LanguageModelProviderSetting {
fn schema_name() -> Cow<'static, str> {
"LanguageModelProviderSetting".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
// list the builtin providers as a subset so that we still auto complete them in the settings
json_schema!({
"anyOf": [
{
"type": "string",
"enum": [
"amazon-bedrock",
"anthropic",
"copilot_chat",
"deepseek",
"google",
"lmstudio",
"mistral",
"ollama",
"openai",
"openai-subscribed",
"opencode",
"openrouter",
"vercel_ai_gateway",
"x_ai",
"x_ai_subscribed",
"zed.dev"
]
},
{
"type": "string",
}
]
})
}
}
impl From<String> for LanguageModelProviderSetting {
fn from(provider: String) -> Self {
Self(provider)
}
}
impl From<&str> for LanguageModelProviderSetting {
fn from(provider: &str) -> Self {
Self(provider.to_string())
}
}
#[with_fallible_options]
#[derive(Default, PartialEq, Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug)]
#[serde(transparent)]
pub struct AllAgentServersSettings(pub HashMap<String, CustomAgentServerSettings>);
impl std::ops::Deref for AllAgentServersSettings {
type Target = HashMap<String, CustomAgentServerSettings>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for AllAgentServersSettings {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq, Eq)]
#[serde(untagged)]
pub enum AgentConfigOptionValue {
ValueId(String),
Boolean(bool),
}
impl AgentConfigOptionValue {
pub fn as_value_id(&self) -> Option<&str> {
match self {
Self::ValueId(value) => Some(value),
Self::Boolean(_) => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Boolean(value) => Some(*value),
Self::ValueId(_) => None,
}
}
}
impl std::fmt::Display for AgentConfigOptionValue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ValueId(value) => formatter.write_str(value),
Self::Boolean(value) => value.fmt(formatter),
}
}
}
impl From<String> for AgentConfigOptionValue {
fn from(value: String) -> Self {
Self::ValueId(value)
}
}
impl From<&str> for AgentConfigOptionValue {
fn from(value: &str) -> Self {
Self::ValueId(value.to_string())
}
}
impl From<bool> for AgentConfigOptionValue {
fn from(value: bool) -> Self {
Self::Boolean(value)
}
}
#[with_fallible_options]
#[derive(Deserialize, Serialize, Clone, JsonSchema, MergeFrom, Debug, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CustomAgentServerSettings {
Custom {
#[serde(rename = "command")]
path: PathBuf,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
args: Vec<String>,
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
/// The default mode to use for this agent.
///
/// Note: Not only all agents support modes.
///
/// Default: None
default_mode: Option<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to the default value for that option.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
default_config_options: HashMap<String, AgentConfigOptionValue>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
favorite_config_option_values: HashMap<String, Vec<String>>,
},
// Used for the ACP extension migration
#[serde(alias = "extension")]
Registry {
/// Additional environment variables to pass to the agent.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
env: HashMap<String, String>,
/// The default mode to use for this agent.
///
/// Note: Not only all agents support modes.
///
/// Default: None
default_mode: Option<String>,
/// Default values for session config options.
///
/// This is a map from config option ID to the default value for that option.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
default_config_options: HashMap<String, AgentConfigOptionValue>,
/// Favorited values for session config options.
///
/// This is a map from config option ID to a list of favorited value IDs.
///
/// Default: {}
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
favorite_config_option_values: HashMap<String, Vec<String>>,
},
}
/// A persisted sandbox writable-path grant. Deserializes from either a bare
/// path string (`"/tmp/x"`, a legacy/hand-authored entry with no resolved
/// target) or an object (`{ "requested": "/tmp/x", "resolved": "/tmp/real" }`).
/// Serializes back as a bare string when `resolved` is `None` and as an object
/// otherwise, so hand-authored bare strings round-trip and Zed-written grants
/// are objects.
#[derive(Clone, Debug, Default, PartialEq, MergeFrom)]
pub struct GrantedWritePathContent {
/// The path exactly as the user/model requested it.
pub requested: PathBuf,
/// The canonical, symlink-resolved target established when the grant was
/// approved. Absent for a bare-string entry.
pub resolved: Option<PathBuf>,
/// Windows/WSL only: whether the canonical target lives on a Windows-hosted
/// (DrvFs) filesystem, whose sandbox-integrity guarantees are weaker. Absent
/// (false) on other platforms and for bare-string entries.
pub on_windows_fs: bool,
}
impl GrantedWritePathContent {
/// The path used for lexical subtree/coverage/dedup logic: the resolved
/// canonical target when known, otherwise the requested path.
fn canonical_or_requested(&self) -> &Path {
self.resolved.as_deref().unwrap_or(&self.requested)
}
}
impl Serialize for GrantedWritePathContent {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match &self.resolved {
None => self.requested.serialize(serializer),
Some(resolved) => {
use serde::ser::SerializeStruct as _;
let field_count = if self.on_windows_fs { 3 } else { 2 };
let mut state =
serializer.serialize_struct("GrantedWritePathContent", field_count)?;
state.serialize_field("requested", &self.requested)?;
state.serialize_field("resolved", resolved)?;
if self.on_windows_fs {
state.serialize_field("on_windows_fs", &self.on_windows_fs)?;
}
state.end()
}
}
}
}
impl<'de> Deserialize<'de> for GrantedWritePathContent {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Object {
requested: PathBuf,
#[serde(default)]
resolved: Option<PathBuf>,
#[serde(default)]
on_windows_fs: bool,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrObject {
String(PathBuf),
Object(Object),
}
Ok(match StringOrObject::deserialize(deserializer)? {
StringOrObject::String(requested) => Self {
requested,
resolved: None,
on_windows_fs: false,
},
StringOrObject::Object(Object {
requested,
resolved,
on_windows_fs,
}) => Self {
requested,
resolved,
on_windows_fs,
},
})
}
}
impl JsonSchema for GrantedWritePathContent {
fn schema_name() -> Cow<'static, str> {
"GrantedWritePathContent".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
json_schema!({
"oneOf": [
{ "type": "string" },
{
"type": "object",
"properties": {
"requested": { "type": "string" },
"resolved": { "type": ["string", "null"] },
"on_windows_fs": { "type": "boolean" }
},
"required": ["requested"]
}
]
})
}
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct SandboxPermissionsContent {
/// Whether sandboxed terminal commands may always reach any host over the
/// network without prompting.
/// Default: false
pub allow_all_hosts: Option<bool>,
/// Hosts that sandboxed terminal commands may always reach over the
/// network without prompting. Each entry is an exact hostname
/// (`github.com`) or a leading-`*.` subdomain wildcard (`*.npmjs.org`).
/// Default: []
pub network_hosts: Option<ExtendingVec<String>>,
/// Whether sandboxed terminal commands may always write anywhere on the
/// filesystem without prompting.
/// Default: false
pub allow_fs_write_all: Option<bool>,
/// Whether to persistently run agent terminal commands outside the OS
/// sandbox. This is the model-facing "off switch": when true, the sandboxed
/// terminal tool is not exposed and the system prompt omits the sandbox
/// section, so the model uses the plain `terminal` tool. On Windows, WSL
/// sandbox setup is skipped. Distinct from the model-requested
/// `unsandboxed: true` escape approved "once" or "for this thread".
/// Default: false
pub allow_unsandboxed: Option<bool>,
/// Directory subtrees that sandboxed terminal commands may always write
/// to without prompting. Each entry is either a bare path string or an
/// object `{requested, resolved}`; Zed writes objects (the canonical,
/// symlink-resolved target established at approval time), while
/// hand-authored entries may be bare path strings. Paths written by Zed
/// are absolute.
/// Default: []
pub write_paths: Option<ExtendingVec<GrantedWritePathContent>>,
/// Whether to warn when a sandbox escalation prompt requests a domain or
/// write path that contains potentially confusable Unicode characters
/// (homoglyphs, invisible characters, or bidirectional overrides). When
/// enabled, such prompts show a warning that must be acknowledged before
/// the request can be allowed.
/// Default: true
pub warn_confusable_unicode: Option<bool>,
/// Whether to warn (Windows/WSL only) when a sandbox grant targets a file on
/// a Windows-hosted (DrvFs) filesystem. Such grants are enforced inside WSL
/// via a translated path, and their sandbox-integrity guarantees are weaker
/// than a distro-native filesystem. When enabled, such grants show a warning
/// that must be acknowledged before the command runs.
/// Default: true
pub warn_ntfs_grants: Option<bool>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ToolPermissionsContent {
/// Global default permission when no tool-specific rules match.
/// Individual tools can override this with their own default.
/// Default: confirm
#[serde(alias = "default_mode")]
pub default: Option<ToolPermissionMode>,
/// Per-tool permission rules.
/// Keys are tool names (e.g. terminal, edit_file, fetch) including MCP
/// tools (e.g. mcp:server_name:tool_name). Any tool name is accepted;
/// even tools without meaningful text input can have a `default` set.
#[serde(default)]
pub tools: HashMap<Arc<str>, ToolRulesContent>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ToolRulesContent {
/// Default mode when no regex rules match.
/// When unset, inherits from the global `tool_permissions.default`.
#[serde(alias = "default_mode")]
pub default: Option<ToolPermissionMode>,
/// Regexes for inputs to auto-approve.
/// For terminal: matches command. For file tools: matches path. For fetch: matches URL.
/// For `copy_path` and `move_path`, patterns are matched independently against each
/// path (source and destination).
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
/// removed by a higher-priority layer—only new patterns can be added.
/// Default: []
pub always_allow: Option<ExtendingVec<ToolRegexRule>>,
/// Regexes for inputs to auto-reject.
/// **SECURITY**: These take precedence over ALL other rules, across ALL settings layers.
/// For `copy_path` and `move_path`, patterns are matched independently against each
/// path (source and destination).
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
/// removed by a higher-priority layer—only new patterns can be added.
/// Default: []
pub always_deny: Option<ExtendingVec<ToolRegexRule>>,
/// Regexes for inputs that must always prompt.
/// Takes precedence over always_allow but not always_deny.
/// For `copy_path` and `move_path`, patterns are matched independently against each
/// path (source and destination).
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
/// removed by a higher-priority layer—only new patterns can be added.
/// Default: []
pub always_confirm: Option<ExtendingVec<ToolRegexRule>>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ToolRegexRule {
/// The regex pattern to match.
#[serde(default)]
pub pattern: String,
/// Whether the regex is case-sensitive.
/// Default: false (case-insensitive)
pub case_sensitive: Option<bool>,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
)]
#[serde(rename_all = "snake_case")]
pub enum ToolPermissionMode {
/// Auto-approve without prompting.
Allow,
/// Auto-reject with an error.
Deny,
/// Always prompt for confirmation (default behavior).
#[default]
Confirm,
}
impl std::fmt::Display for ToolPermissionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ToolPermissionMode::Allow => write!(f, "Allow"),
ToolPermissionMode::Deny => write!(f, "Deny"),
ToolPermissionMode::Confirm => write!(f, "Confirm"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_config_option_value_serializes_value_id_as_string() {
let value = AgentConfigOptionValue::from("manual");
assert_eq!(
serde_json::to_value(&value).expect("serialize value id"),
serde_json::json!("manual")
);
assert_eq!(
serde_json::from_value::<AgentConfigOptionValue>(serde_json::json!("manual"))
.expect("deserialize value id"),
AgentConfigOptionValue::ValueId("manual".to_string())
);
}
#[test]
fn agent_config_option_value_serializes_boolean_as_boolean() {
let value = AgentConfigOptionValue::Boolean(true);
assert_eq!(
serde_json::to_value(&value).expect("serialize boolean"),
serde_json::json!(true)
);
assert_eq!(
serde_json::from_value::<AgentConfigOptionValue>(serde_json::json!(true))
.expect("deserialize boolean"),
AgentConfigOptionValue::Boolean(true)
);
}
#[test]
fn agent_config_option_value_merge_replaces_existing_value() {
use crate::merge_from::MergeFrom as _;
let mut value = AgentConfigOptionValue::ValueId("manual".to_string());
value.merge_from(&AgentConfigOptionValue::Boolean(true));
assert_eq!(value, AgentConfigOptionValue::Boolean(true));
}
#[test]
fn test_set_tool_default_permission_creates_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.tool_permissions.is_none());
settings.set_tool_default_permission("terminal", ToolPermissionMode::Allow);
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
}
#[test]
fn test_set_tool_default_permission_updates_existing() {
let mut settings = AgentSettingsContent::default();
settings.set_tool_default_permission("terminal", ToolPermissionMode::Confirm);
settings.set_tool_default_permission("terminal", ToolPermissionMode::Allow);
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
}
#[test]
fn test_set_tool_default_permission_for_mcp_tool() {
let mut settings = AgentSettingsContent::default();
settings.set_tool_default_permission("mcp:github:create_issue", ToolPermissionMode::Allow);
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let mcp_rules = tool_permissions
.tools
.get("mcp:github:create_issue")
.unwrap();
assert_eq!(mcp_rules.default, Some(ToolPermissionMode::Allow));
}
#[test]
fn test_add_tool_allow_pattern_creates_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.tool_permissions.is_none());
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(always_allow.0.len(), 1);
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
}
#[test]
fn test_add_tool_allow_pattern_appends_to_existing() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("terminal", "^npm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(always_allow.0.len(), 2);
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
assert_eq!(always_allow.0[1].pattern, "^npm\\s");
}
#[test]
fn test_add_tool_allow_pattern_does_not_duplicate() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(
always_allow.0.len(),
1,
"Duplicate patterns should not be added"
);
}
#[test]
fn test_add_tool_allow_pattern_for_different_tools() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("fetch", "^https?://github\\.com".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
assert_eq!(
terminal_rules.always_allow.as_ref().unwrap().0[0].pattern,
"^cargo\\s"
);
let fetch_rules = tool_permissions.tools.get("fetch").unwrap();
assert_eq!(
fetch_rules.always_allow.as_ref().unwrap().0[0].pattern,
"^https?://github\\.com"
);
}
#[test]
fn test_add_tool_deny_pattern_creates_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.tool_permissions.is_none());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(always_deny.0.len(), 1);
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
}
#[test]
fn test_add_tool_deny_pattern_appends_to_existing() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^sudo\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(always_deny.0.len(), 2);
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
assert_eq!(always_deny.0[1].pattern, "^sudo\\s");
}
#[test]
fn test_add_tool_deny_pattern_does_not_duplicate() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(
always_deny.0.len(),
1,
"Duplicate patterns should not be added"
);
}
#[test]
fn test_add_tool_deny_and_allow_patterns_separate() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(always_allow.0.len(), 1);
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(always_deny.0.len(), 1);
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
}
#[test]
fn test_allow_sandbox_permissions_create_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.sandbox_permissions.is_none());
settings.allow_sandbox_all_hosts();
assert_eq!(settings.sandbox_network_hosts(), &[] as &[String]);
settings
.set_sandbox_network_hosts(vec!["github.com".to_string(), "*.npmjs.org".to_string()]);
assert_eq!(
settings.sandbox_network_hosts(),
&["github.com".to_string(), "*.npmjs.org".to_string()]
);
settings.allow_sandbox_fs_write_all();
settings.allow_sandbox_unsandboxed();
settings.add_sandbox_write_path(GrantedWritePathContent {
requested: PathBuf::from("/tmp/build"),
resolved: None,
on_windows_fs: false,
});
let sandbox_permissions = settings.sandbox_permissions.as_ref().unwrap();
assert_eq!(sandbox_permissions.allow_all_hosts, Some(true));
assert_eq!(
sandbox_permissions
.network_hosts
.as_ref()
.unwrap()
.0
.as_slice(),
&["github.com".to_string(), "*.npmjs.org".to_string()]
);
assert_eq!(sandbox_permissions.allow_fs_write_all, Some(true));
assert_eq!(sandbox_permissions.allow_unsandboxed, Some(true));
assert_eq!(
sandbox_permissions
.write_paths
.as_ref()
.unwrap()
.0
.as_slice(),
&[GrantedWritePathContent {
requested: PathBuf::from("/tmp/build"),
resolved: None,
on_windows_fs: false,
}]
);
}
#[test]
fn test_add_sandbox_write_path_prunes_redundant_paths() {
let mut settings = AgentSettingsContent::default();
settings.add_sandbox_write_path(GrantedWritePathContent {
requested: PathBuf::from("/tmp/build/cache"),
resolved: None,
on_windows_fs: false,
});
settings.add_sandbox_write_path(GrantedWritePathContent {
requested: PathBuf::from("/tmp/build"),
resolved: None,
on_windows_fs: false,
});
settings.add_sandbox_write_path(GrantedWritePathContent {
requested: PathBuf::from("/tmp/build/output"),
resolved: None,
on_windows_fs: false,
});
let write_paths = settings
.sandbox_permissions
.as_ref()
.unwrap()
.write_paths
.as_ref()
.unwrap()
.0
.as_slice();
assert_eq!(
write_paths,
&[GrantedWritePathContent {
requested: PathBuf::from("/tmp/build"),
resolved: None,
on_windows_fs: false,
}]
);
}
#[test]
fn test_granted_write_path_content_deserializes_string_or_object() {
let from_string: GrantedWritePathContent =
serde_json::from_value(serde_json::json!("/tmp/x")).unwrap();
assert_eq!(
from_string,
GrantedWritePathContent {
requested: PathBuf::from("/tmp/x"),
resolved: None,
on_windows_fs: false,
}
);
let from_object: GrantedWritePathContent = serde_json::from_value(
serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" }),
)
.unwrap();
assert_eq!(
from_object,
GrantedWritePathContent {
requested: PathBuf::from("/tmp/x"),
resolved: Some(PathBuf::from("/tmp/real")),
on_windows_fs: false,
}
);
// `resolved` is optional in the object form.
let object_without_resolved: GrantedWritePathContent =
serde_json::from_value(serde_json::json!({ "requested": "/tmp/x" })).unwrap();
assert_eq!(
object_without_resolved,
GrantedWritePathContent {
requested: PathBuf::from("/tmp/x"),
resolved: None,
on_windows_fs: false,
}
);
}
#[test]
fn test_granted_write_path_content_serializes_string_or_object() {
// No resolved target serializes as a bare string, so hand-authored
// bare strings round-trip.
let bare = GrantedWritePathContent {
requested: PathBuf::from("/tmp/x"),
resolved: None,
on_windows_fs: false,
};
assert_eq!(
serde_json::to_value(&bare).unwrap(),
serde_json::json!("/tmp/x")
);
// A resolved target serializes as an object.
let resolved = GrantedWritePathContent {
requested: PathBuf::from("/tmp/x"),
resolved: Some(PathBuf::from("/tmp/real")),
on_windows_fs: false,
};
assert_eq!(
serde_json::to_value(&resolved).unwrap(),
serde_json::json!({ "requested": "/tmp/x", "resolved": "/tmp/real" })
);
}
}