Cargo.lock ๐
@@ -15197,6 +15197,8 @@ dependencies = [
name = "settings_ui"
version = "0.1.0"
dependencies = [
+ "agent",
+ "agent_settings",
"anyhow",
"assets",
"bm25",
Danilo Leal and Richard Feldman created
This PR adds a page in the settings UI, under the AI section, that
allows to interact and customize permissions for tool calling for each
tool available to Zed's native agent.
Release Notes:
- AI: Added a settings page in the settings editor that allows to
customize tool call permissions for each tool.
---------
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
Cargo.lock | 2
crates/settings_ui/Cargo.toml | 3
crates/settings_ui/src/components/input_field.rs | 178 ++
crates/settings_ui/src/page_data.rs | 13
crates/settings_ui/src/pages.rs | 9
crates/settings_ui/src/pages/tool_permissions_setup.rs | 923 ++++++++++++
crates/settings_ui/src/settings_ui.rs | 101 +
crates/title_bar/Cargo.toml | 1
crates/ui/src/components/banner.rs | 13
crates/zed/src/visual_test_runner.rs | 247 +++
10 files changed, 1,462 insertions(+), 28 deletions(-)
@@ -15197,6 +15197,8 @@ dependencies = [
name = "settings_ui"
version = "0.1.0"
dependencies = [
+ "agent",
+ "agent_settings",
"anyhow",
"assets",
"bm25",
@@ -16,6 +16,8 @@ default = []
test-support = []
[dependencies]
+agent.workspace = true
+agent_settings.workspace = true
anyhow.workspace = true
bm25 = "2.3.2"
component.workspace = true
@@ -43,6 +45,7 @@ release_channel.workspace = true
schemars.workspace = true
search.workspace = true
serde.workspace = true
+serde_json.workspace = true
settings.workspace = true
strum.workspace = true
telemetry.workspace = true
@@ -1,29 +1,46 @@
+use std::rc::Rc;
+
use editor::Editor;
-use gpui::{Focusable, div};
-use ui::{
- ActiveTheme as _, App, FluentBuilder as _, InteractiveElement as _, IntoElement,
- ParentElement as _, RenderOnce, Styled as _, Window,
-};
+use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement};
+use settings::Settings as _;
+use theme::ThemeSettings;
+use ui::{Tooltip, prelude::*, rems};
#[derive(IntoElement)]
pub struct SettingsInputField {
+ id: Option<ElementId>,
initial_text: Option<String>,
placeholder: Option<&'static str>,
- confirm: Option<Box<dyn Fn(Option<String>, &mut Window, &mut App)>>,
+ confirm: Option<Rc<dyn Fn(Option<String>, &mut Window, &mut App)>>,
tab_index: Option<isize>,
+ use_buffer_font: bool,
+ display_confirm_button: bool,
+ display_clear_button: bool,
+ action_slot: Option<AnyElement>,
+ color: Option<Color>,
}
-// TODO: Update the `ui_input::InputField` to use `window.use_state` and `RenceOnce` and remove this component
impl SettingsInputField {
pub fn new() -> Self {
Self {
+ id: None,
initial_text: None,
placeholder: None,
confirm: None,
tab_index: None,
+ use_buffer_font: false,
+ display_confirm_button: false,
+ display_clear_button: false,
+ action_slot: None,
+ color: None,
}
}
+ pub fn with_id(mut self, id: impl Into<ElementId>) -> Self {
+ self.id = Some(id.into());
+ self
+ }
+
pub fn with_initial_text(mut self, initial_text: String) -> Self {
self.initial_text = Some(initial_text);
self
@@ -38,7 +55,22 @@ impl SettingsInputField {
mut self,
confirm: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
) -> Self {
- self.confirm = Some(Box::new(confirm));
+ self.confirm = Some(Rc::new(confirm));
+ self
+ }
+
+ pub fn display_confirm_button(mut self) -> Self {
+ self.display_confirm_button = true;
+ self
+ }
+
+ pub fn display_clear_button(mut self) -> Self {
+ self.display_clear_button = true;
+ self
+ }
+
+ pub fn action_slot(mut self, action: impl IntoElement) -> Self {
+ self.action_slot = Some(action.into_any_element());
self
}
@@ -46,33 +78,84 @@ impl SettingsInputField {
self.tab_index = Some(arg);
self
}
+
+ pub fn with_buffer_font(mut self) -> Self {
+ self.use_buffer_font = true;
+ self
+ }
+
+ pub fn color(mut self, color: Color) -> Self {
+ self.color = Some(color);
+ self
+ }
}
impl RenderOnce for SettingsInputField {
fn render(self, window: &mut Window, cx: &mut App) -> impl ui::IntoElement {
- let editor = window.use_state(cx, {
- move |window, cx| {
- let mut editor = Editor::single_line(window, cx);
- if let Some(text) = self.initial_text {
- editor.set_text(text, window, cx);
+ let settings = ThemeSettings::get_global(cx);
+ let use_buffer_font = self.use_buffer_font;
+ let color = self.color.map(|c| c.color(cx));
+ let styles = TextStyleRefinement {
+ font_family: use_buffer_font.then(|| settings.buffer_font.family.clone()),
+ font_size: use_buffer_font.then(|| rems(0.75).into()),
+ color,
+ ..Default::default()
+ };
+
+ let editor = if let Some(id) = self.id {
+ window.use_keyed_state(id, cx, {
+ let initial_text = self.initial_text.clone();
+ let placeholder = self.placeholder;
+ move |window, cx| {
+ let mut editor = Editor::single_line(window, cx);
+ if let Some(text) = initial_text {
+ editor.set_text(text, window, cx);
+ }
+
+ if let Some(placeholder) = placeholder {
+ editor.set_placeholder_text(placeholder, window, cx);
+ }
+ editor.set_text_style_refinement(styles);
+ editor
}
+ })
+ } else {
+ window.use_state(cx, {
+ let initial_text = self.initial_text.clone();
+ let placeholder = self.placeholder;
+ move |window, cx| {
+ let mut editor = Editor::single_line(window, cx);
+ if let Some(text) = initial_text {
+ editor.set_text(text, window, cx);
+ }
- if let Some(placeholder) = self.placeholder {
- editor.set_placeholder_text(placeholder, window, cx);
+ if let Some(placeholder) = placeholder {
+ editor.set_placeholder_text(placeholder, window, cx);
+ }
+ editor.set_text_style_refinement(styles);
+ editor
}
- // todo(settings_ui): We should have an observe global use for settings store
- // so whenever a settings file is updated, the settings ui updates too
- editor
- }
- });
+ })
+ };
let weak_editor = editor.downgrade();
+ let weak_editor_for_button = editor.downgrade();
+ let weak_editor_for_clear = editor.downgrade();
let theme_colors = cx.theme().colors();
- div()
+ let display_confirm_button = self.display_confirm_button;
+ let display_clear_button = self.display_clear_button;
+ let confirm_for_button = self.confirm.clone();
+ let is_editor_empty = editor.read(cx).text(cx).trim().is_empty();
+ let is_editor_focused = editor.read(cx).is_focused(window);
+
+ h_flex()
+ .group("settings-input-field-editor")
+ .relative()
.py_1()
.px_2()
+ .h_8()
.min_w_64()
.rounded_md()
.border_1()
@@ -84,6 +167,59 @@ impl RenderOnce for SettingsInputField {
.focus(|s| s.border_color(theme_colors.border_focused))
})
.child(editor)
+ .child(
+ h_flex()
+ .absolute()
+ .top_1()
+ .right_1()
+ .invisible()
+ .when(is_editor_focused, |this| this.visible())
+ .group_hover("settings-input-field-editor", |this| this.visible())
+ .when(
+ display_clear_button && !is_editor_empty && is_editor_focused,
+ |this| {
+ this.child(
+ IconButton::new("clear-button", IconName::Close)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("Clear"))
+ .on_click(move |_, window, cx| {
+ let Some(editor) = weak_editor_for_clear.upgrade() else {
+ return;
+ };
+ editor.update(cx, |editor, cx| {
+ editor.set_text("", window, cx);
+ });
+ }),
+ )
+ },
+ )
+ .when(
+ display_confirm_button && !is_editor_empty && is_editor_focused,
+ |this| {
+ this.child(
+ IconButton::new("confirm-button", IconName::Check)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Success)
+ .tooltip(Tooltip::text("Enter to Confirm"))
+ .on_click(move |_, window, cx| {
+ let Some(confirm) = confirm_for_button.as_ref() else {
+ return;
+ };
+ let Some(editor) = weak_editor_for_button.upgrade() else {
+ return;
+ };
+ let new_value =
+ editor.read_with(cx, |editor, cx| editor.text(cx));
+ let new_value =
+ (!new_value.is_empty()).then_some(new_value);
+ confirm(new_value, window, cx);
+ }),
+ )
+ },
+ )
+ .when_some(self.action_slot, |this, action| this.child(action)),
+ )
.when_some(self.confirm, |this, confirm| {
this.on_action::<menu::Confirm>({
move |_, window, cx| {
@@ -7,7 +7,7 @@ use ui::IntoElement;
use crate::{
ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata,
SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names,
- pages::render_edit_prediction_setup_page,
+ pages::{render_edit_prediction_setup_page, render_tool_permissions_setup_page},
};
const DEFAULT_STRING: String = String::new();
@@ -6869,7 +6869,7 @@ fn ai_page() -> SettingsPage {
]
}
- fn agent_configuration_section() -> [SettingsPageItem; 12] {
+ fn agent_configuration_section() -> [SettingsPageItem; 13] {
[
SettingsPageItem::SectionHeader("Agent Configuration"),
SettingsPageItem::SettingItem(SettingItem {
@@ -6894,6 +6894,15 @@ fn ai_page() -> SettingsPage {
metadata: None,
files: USER,
}),
+ SettingsPageItem::SubPageLink(SubPageLink {
+ title: "Configure Tool Rules".into(),
+ r#type: Default::default(),
+ json_path: Some("agent.tool_permissions"),
+ description: Some("Set up regex patterns to auto-allow, auto-deny, or always prompt for specific tool inputs.".into()),
+ in_json: true,
+ files: USER,
+ render: render_tool_permissions_setup_page,
+ }),
SettingsPageItem::SettingItem(SettingItem {
title: "Single File Review",
description: "When enabled, agent edits will also be displayed in single-file buffers for review.",
@@ -1,2 +1,11 @@
mod edit_prediction_provider_setup;
+mod tool_permissions_setup;
+
pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page;
+pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page;
+
+pub use tool_permissions_setup::{
+ render_create_directory_tool_config, render_delete_path_tool_config,
+ render_edit_file_tool_config, render_fetch_tool_config, render_move_path_tool_config,
+ render_save_file_tool_config, render_terminal_tool_config, render_web_search_tool_config,
+};
@@ -0,0 +1,923 @@
+use agent::{HARDCODED_SECURITY_RULES, ToolPermissionDecision};
+use agent_settings::AgentSettings;
+use gpui::{Focusable, ReadGlobal, ScrollHandle, TextStyleRefinement, point, prelude::*};
+use settings::{Settings as _, SettingsStore, ToolPermissionMode};
+use std::sync::Arc;
+use theme::ThemeSettings;
+use ui::{Banner, ContextMenu, Divider, PopoverMenu, Tooltip, prelude::*};
+use util::shell::ShellKind;
+
+use crate::{SettingsWindow, components::SettingsInputField};
+
+/// Tools that support permission rules
+const TOOLS: &[ToolInfo] = &[
+ ToolInfo {
+ id: "terminal",
+ name: "Terminal",
+ description: "Commands executed in the terminal",
+ regex_explanation: "Patterns are matched against each command in the input. Commands chained with &&, ||, ;, or pipes are split and checked individually.",
+ },
+ ToolInfo {
+ id: "edit_file",
+ name: "Edit File",
+ description: "File editing operations",
+ regex_explanation: "Patterns are matched against the file path being edited.",
+ },
+ ToolInfo {
+ id: "delete_path",
+ name: "Delete Path",
+ description: "File and directory deletion",
+ regex_explanation: "Patterns are matched against the path being deleted.",
+ },
+ ToolInfo {
+ id: "move_path",
+ name: "Move Path",
+ description: "File and directory moves/renames",
+ regex_explanation: "Patterns are matched against both the source and destination paths.",
+ },
+ ToolInfo {
+ id: "create_directory",
+ name: "Create Directory",
+ description: "Directory creation",
+ regex_explanation: "Patterns are matched against the directory path being created.",
+ },
+ ToolInfo {
+ id: "save_file",
+ name: "Save File",
+ description: "File saving operations",
+ regex_explanation: "Patterns are matched against the file path being saved.",
+ },
+ ToolInfo {
+ id: "fetch",
+ name: "Fetch",
+ description: "HTTP requests to URLs",
+ regex_explanation: "Patterns are matched against the URL being fetched.",
+ },
+ ToolInfo {
+ id: "web_search",
+ name: "Web Search",
+ description: "Web search queries",
+ regex_explanation: "Patterns are matched against the search query.",
+ },
+];
+
+struct ToolInfo {
+ id: &'static str,
+ name: &'static str,
+ description: &'static str,
+ regex_explanation: &'static str,
+}
+
+/// Renders the main tool permissions setup page showing a list of tools
+pub(crate) fn render_tool_permissions_setup_page(
+ settings_window: &SettingsWindow,
+ scroll_handle: &ScrollHandle,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let tool_items: Vec<AnyElement> = TOOLS
+ .iter()
+ .enumerate()
+ .map(|(i, tool)| render_tool_list_item(settings_window, tool, i, window, cx))
+ .collect();
+
+ let page_description =
+ "Configure regex patterns to control which tool actions require confirmation.";
+
+ let scroll_step = px(40.);
+
+ v_flex()
+ .id("tool-permissions-page")
+ .on_action({
+ let scroll_handle = scroll_handle.clone();
+ move |_: &menu::SelectNext, window, cx| {
+ window.focus_next(cx);
+ let current_offset = scroll_handle.offset();
+ scroll_handle.set_offset(point(current_offset.x, current_offset.y - scroll_step));
+ }
+ })
+ .on_action({
+ let scroll_handle = scroll_handle.clone();
+ move |_: &menu::SelectPrevious, window, cx| {
+ window.focus_prev(cx);
+ let current_offset = scroll_handle.offset();
+ scroll_handle.set_offset(point(current_offset.x, current_offset.y + scroll_step));
+ }
+ })
+ .min_w_0()
+ .size_full()
+ .pt_2p5()
+ .px_8()
+ .pb_16()
+ .overflow_y_scroll()
+ .track_scroll(scroll_handle)
+ .child(Label::new("Tool Permission Rules").size(LabelSize::Large))
+ .child(
+ Label::new(page_description)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ v_flex()
+ .mt_4()
+ .children(tool_items.into_iter().enumerate().flat_map(|(i, item)| {
+ let mut elements: Vec<AnyElement> = vec![item];
+ if i + 1 < TOOLS.len() {
+ elements.push(Divider::horizontal().into_any_element());
+ }
+ elements
+ })),
+ )
+ .into_any_element()
+}
+
+fn render_tool_list_item(
+ _settings_window: &SettingsWindow,
+ tool: &'static ToolInfo,
+ tool_index: usize,
+ _window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let rules = get_tool_rules(tool.id, cx);
+ let rule_count =
+ rules.always_allow.len() + rules.always_deny.len() + rules.always_confirm.len();
+
+ let rule_summary = if rule_count > 0 {
+ Some(format!("{} rules", rule_count))
+ } else {
+ None
+ };
+
+ let render_fn = get_tool_render_fn(tool.id);
+
+ h_flex()
+ .w_full()
+ .py_3()
+ .justify_between()
+ .child(
+ v_flex()
+ .child(h_flex().gap_1().child(Label::new(tool.name)).when_some(
+ rule_summary,
+ |this, summary| {
+ this.child(
+ Label::new(summary)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ },
+ ))
+ .child(
+ Label::new(tool.description)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child({
+ let tool_name = tool.name;
+ Button::new(format!("configure-{}", tool.id), "Configure")
+ .tab_index(tool_index as isize)
+ .style(ButtonStyle::OutlinedGhost)
+ .size(ButtonSize::Medium)
+ .icon(IconName::ChevronRight)
+ .icon_position(IconPosition::End)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::Small)
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.push_dynamic_sub_page(
+ tool_name,
+ "Configure Tool Rules",
+ None,
+ render_fn,
+ window,
+ cx,
+ );
+ }))
+ })
+ .into_any_element()
+}
+
+fn get_tool_render_fn(
+ tool_id: &str,
+) -> fn(&SettingsWindow, &ScrollHandle, &mut Window, &mut Context<SettingsWindow>) -> AnyElement {
+ match tool_id {
+ "terminal" => render_terminal_tool_config,
+ "edit_file" => render_edit_file_tool_config,
+ "delete_path" => render_delete_path_tool_config,
+ "move_path" => render_move_path_tool_config,
+ "create_directory" => render_create_directory_tool_config,
+ "save_file" => render_save_file_tool_config,
+ "fetch" => render_fetch_tool_config,
+ "web_search" => render_web_search_tool_config,
+ _ => render_terminal_tool_config, // fallback
+ }
+}
+
+/// Renders an individual tool's permission configuration page
+pub(crate) fn render_tool_config_page(
+ tool_id: &'static str,
+ _settings_window: &SettingsWindow,
+ scroll_handle: &ScrollHandle,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let tool = TOOLS.iter().find(|t| t.id == tool_id).unwrap();
+ let rules = get_tool_rules(tool_id, cx);
+ let page_title = format!("{} Tool", tool.name);
+ let scroll_step = px(80.);
+
+ v_flex()
+ .id(format!("tool-config-page-{}", tool_id))
+ .on_action({
+ let scroll_handle = scroll_handle.clone();
+ move |_: &menu::SelectNext, window, cx| {
+ window.focus_next(cx);
+ let current_offset = scroll_handle.offset();
+ scroll_handle.set_offset(point(current_offset.x, current_offset.y - scroll_step));
+ }
+ })
+ .on_action({
+ let scroll_handle = scroll_handle.clone();
+ move |_: &menu::SelectPrevious, window, cx| {
+ window.focus_prev(cx);
+ let current_offset = scroll_handle.offset();
+ scroll_handle.set_offset(point(current_offset.x, current_offset.y + scroll_step));
+ }
+ })
+ .min_w_0()
+ .size_full()
+ .pt_2p5()
+ .px_8()
+ .pb_16()
+ .overflow_y_scroll()
+ .track_scroll(scroll_handle)
+ .child(
+ v_flex()
+ .min_w_0()
+ .child(Label::new(page_title).size(LabelSize::Large))
+ .child(
+ Label::new(tool.regex_explanation)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .when(tool_id == "terminal", |this| {
+ this.child(render_hardcoded_security_banner(cx))
+ })
+ .child(render_verification_section(tool_id, window, cx))
+ .child(
+ v_flex()
+ .mt_6()
+ .min_w_0()
+ .w_full()
+ .gap_5()
+ .child(render_default_mode_section(tool_id, rules.default_mode, cx))
+ .child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
+ .child(render_rule_section(
+ tool_id,
+ "Always Deny",
+ "If any of these regexes match, the tool action will be denied.",
+ RuleType::Deny,
+ &rules.always_deny,
+ cx,
+ ))
+ .child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
+ .child(render_rule_section(
+ tool_id,
+ "Always Allow",
+ "If any of these regexes match, the tool action will be approvedโunless an Always Confirm or Always Deny regex matches.",
+ RuleType::Allow,
+ &rules.always_allow,
+ cx,
+ ))
+ .child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
+ .child(render_rule_section(
+ tool_id,
+ "Always Confirm",
+ "If any of these regexes match, a confirmation will be shown unless an Always Deny regex matches.",
+ RuleType::Confirm,
+ &rules.always_confirm,
+ cx,
+ )),
+ )
+ .into_any_element()
+}
+
+fn render_hardcoded_security_banner(cx: &mut Context<SettingsWindow>) -> AnyElement {
+ let pattern_labels = HARDCODED_SECURITY_RULES.terminal_deny.iter().map(|rule| {
+ h_flex()
+ .gap_1()
+ .child(
+ Icon::new(IconName::Dash)
+ .color(Color::Hidden)
+ .size(IconSize::Small),
+ )
+ .child(
+ Label::new(rule.pattern.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx),
+ )
+ });
+
+ v_flex()
+ .mt_3()
+ .child(
+ Banner::new().child(
+ v_flex()
+ .py_1()
+ .gap_1()
+ .child(
+ Label::new(
+ "The following patterns are always blocked and cannot be overridden:",
+ )
+ .size(LabelSize::Small),
+ )
+ .children(pattern_labels),
+ ),
+ )
+ .into_any_element()
+}
+
+fn render_verification_section(
+ tool_id: &'static str,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let input_id = format!("{}-verification-input", tool_id);
+
+ let settings = AgentSettings::get_global(cx);
+ let always_allow_enabled = settings.always_allow_tool_actions;
+
+ let editor = window.use_keyed_state(input_id, cx, |window, cx| {
+ let mut editor = editor::Editor::single_line(window, cx);
+ editor.set_placeholder_text("Enter a rule to see how it appliesโฆ", window, cx);
+
+ let global_settings = ThemeSettings::get_global(cx);
+ editor.set_text_style_refinement(TextStyleRefinement {
+ font_family: Some(global_settings.buffer_font.family.clone()),
+ font_size: Some(rems(0.75).into()),
+ ..Default::default()
+ });
+
+ editor
+ });
+
+ cx.observe(&editor, |_, _, cx| cx.notify()).detach();
+
+ let focus_handle = editor.focus_handle(cx).tab_index(0).tab_stop(true);
+
+ let current_text = editor.read(cx).text(cx);
+ let (decision, matched_patterns) = if current_text.is_empty() {
+ (None, Vec::new())
+ } else {
+ let matches = find_matched_patterns(tool_id, ¤t_text, cx);
+ let decision = evaluate_test_input(tool_id, ¤t_text, cx);
+ (Some(decision), matches)
+ };
+
+ let always_allow_description = "The Always Allow Tool Actions setting is enabled: all tools will be allowed regardless of these rules.";
+ let theme_colors = cx.theme().colors();
+
+ v_flex()
+ .mt_3()
+ .min_w_0()
+ .gap_2()
+ .when(always_allow_enabled, |this| {
+ this.child(
+ Banner::new()
+ .severity(Severity::Warning)
+ .wrap_content(false)
+ .child(
+ Label::new(always_allow_description)
+ .size(LabelSize::Small)
+ .mt(px(3.))
+ .mr_8(),
+ )
+ .action_slot(
+ Button::new("configure_setting", "Configure Setting")
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.navigate_to_setting(
+ "agent.always_allow_tool_actions",
+ window,
+ cx,
+ );
+ })),
+ ),
+ )
+ })
+ .child(
+ v_flex()
+ .p_2p5()
+ .gap_1p5()
+ .bg(theme_colors.surface_background.opacity(0.15))
+ .border_1()
+ .border_dashed()
+ .border_color(theme_colors.border_variant)
+ .rounded_sm()
+ .child(
+ Label::new("Test Your Rules")
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .h_8()
+ .px_2()
+ .rounded_md()
+ .border_1()
+ .border_color(theme_colors.border)
+ .bg(theme_colors.editor_background)
+ .track_focus(&focus_handle)
+ .child(editor),
+ )
+ .when(decision.is_some(), |this| {
+ if matched_patterns.is_empty() {
+ this.child(
+ Label::new("No regex matches, using the default action (confirm).")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ } else {
+ this.child(render_matched_patterns(&matched_patterns, cx))
+ }
+ }),
+ )
+ .into_any_element()
+}
+
+#[derive(Clone, Debug)]
+struct MatchedPattern {
+ pattern: String,
+ rule_type: RuleType,
+ is_overridden: bool,
+}
+
+fn find_matched_patterns(tool_id: &str, input: &str, cx: &App) -> Vec<MatchedPattern> {
+ let settings = AgentSettings::get_global(cx);
+ let rules = match settings.tool_permissions.tools.get(tool_id) {
+ Some(rules) => rules,
+ None => return Vec::new(),
+ };
+
+ let mut matched = Vec::new();
+ let mut has_deny_match = false;
+ let mut has_confirm_match = false;
+
+ for rule in &rules.always_deny {
+ if rule.is_match(input) {
+ has_deny_match = true;
+ matched.push(MatchedPattern {
+ pattern: rule.pattern.clone(),
+ rule_type: RuleType::Deny,
+ is_overridden: false,
+ });
+ }
+ }
+
+ for rule in &rules.always_confirm {
+ if rule.is_match(input) {
+ has_confirm_match = true;
+ matched.push(MatchedPattern {
+ pattern: rule.pattern.clone(),
+ rule_type: RuleType::Confirm,
+ is_overridden: has_deny_match,
+ });
+ }
+ }
+
+ for rule in &rules.always_allow {
+ if rule.is_match(input) {
+ matched.push(MatchedPattern {
+ pattern: rule.pattern.clone(),
+ rule_type: RuleType::Allow,
+ is_overridden: has_deny_match || has_confirm_match,
+ });
+ }
+ }
+
+ matched
+}
+
+fn render_matched_patterns(patterns: &[MatchedPattern], cx: &App) -> AnyElement {
+ v_flex()
+ .gap_1()
+ .children(patterns.iter().map(|pattern| {
+ let (type_label, color) = match pattern.rule_type {
+ RuleType::Deny => ("Always Deny", Color::Error),
+ RuleType::Confirm => ("Always Confirm", Color::Warning),
+ RuleType::Allow => ("Always Allow", Color::Success),
+ };
+
+ let type_color = if pattern.is_overridden {
+ Color::Muted
+ } else {
+ color
+ };
+
+ h_flex()
+ .gap_1()
+ .child(
+ Label::new(pattern.pattern.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx)
+ .when(pattern.is_overridden, |this| this.strikethrough()),
+ )
+ .child(
+ Icon::new(IconName::Dash)
+ .size(IconSize::Small)
+ .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.4))),
+ )
+ .child(
+ Label::new(type_label)
+ .size(LabelSize::XSmall)
+ .color(type_color)
+ .when(pattern.is_overridden, |this| {
+ this.strikethrough().alpha(0.5)
+ }),
+ )
+ }))
+ .into_any_element()
+}
+
+fn evaluate_test_input(tool_id: &str, input: &str, cx: &App) -> ToolPermissionDecision {
+ let settings = AgentSettings::get_global(cx);
+
+ // Always pass false for always_allow_tool_actions so we test the actual rules,
+ // not the global override that bypasses all checks.
+ // ShellKind is only used for terminal tool's hardcoded security rules;
+ // for other tools, the check returns None immediately.
+ ToolPermissionDecision::from_input(
+ tool_id,
+ input,
+ &settings.tool_permissions,
+ false,
+ ShellKind::system(),
+ )
+}
+
+fn render_rule_section(
+ tool_id: &'static str,
+ title: &'static str,
+ description: &'static str,
+ rule_type: RuleType,
+ patterns: &[String],
+ cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let section_id = format!("{}-{:?}-section", tool_id, rule_type);
+
+ let user_patterns: Vec<_> = patterns.iter().enumerate().collect();
+
+ v_flex()
+ .id(section_id)
+ .child(Label::new(title))
+ .child(
+ Label::new(description)
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ v_flex()
+ .mt_2()
+ .w_full()
+ .gap_1p5()
+ .when(patterns.is_empty(), |this| {
+ this.child(render_pattern_empty_state(cx))
+ })
+ .when(!user_patterns.is_empty(), |this| {
+ this.child(v_flex().gap_1p5().children(user_patterns.iter().map(
+ |(index, pattern)| {
+ render_user_pattern_row(
+ tool_id,
+ rule_type,
+ *index,
+ (*pattern).clone(),
+ cx,
+ )
+ },
+ )))
+ })
+ .child(render_add_pattern_input(tool_id, rule_type, cx)),
+ )
+ .into_any_element()
+}
+
+fn render_pattern_empty_state(cx: &mut Context<SettingsWindow>) -> AnyElement {
+ h_flex()
+ .p_2()
+ .rounded_md()
+ .border_1()
+ .border_dashed()
+ .border_color(cx.theme().colors().border_variant)
+ .child(
+ Label::new("No patterns configured")
+ .size(LabelSize::Small)
+ .color(Color::Disabled),
+ )
+ .into_any_element()
+}
+
+fn render_user_pattern_row(
+ tool_id: &'static str,
+ rule_type: RuleType,
+ index: usize,
+ pattern: String,
+ cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let pattern_for_delete = pattern.clone();
+ let pattern_for_update = pattern.clone();
+ let tool_id_for_delete = tool_id.to_string();
+ let tool_id_for_update = tool_id.to_string();
+ let input_id = format!("{}-{:?}-pattern-{}", tool_id, rule_type, index);
+ let delete_id = format!("{}-{:?}-delete-{}", tool_id, rule_type, index);
+
+ SettingsInputField::new()
+ .with_id(input_id)
+ .with_initial_text(pattern)
+ .tab_index(0)
+ .with_buffer_font()
+ .color(Color::Default)
+ .action_slot(
+ IconButton::new(delete_id, IconName::Trash)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .tooltip(Tooltip::text("Delete Pattern"))
+ .on_click(cx.listener(move |_, _, _, cx| {
+ delete_pattern(&tool_id_for_delete, rule_type, &pattern_for_delete, cx);
+ })),
+ )
+ .on_confirm(move |new_pattern, _window, cx| {
+ if let Some(new_pattern) = new_pattern {
+ let new_pattern = new_pattern.trim().to_string();
+ if !new_pattern.is_empty() && new_pattern != pattern_for_update {
+ update_pattern(
+ &tool_id_for_update,
+ rule_type,
+ &pattern_for_update,
+ new_pattern,
+ cx,
+ );
+ }
+ }
+ })
+ .into_any_element()
+}
+
+fn render_add_pattern_input(
+ tool_id: &'static str,
+ rule_type: RuleType,
+ _cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let tool_id_owned = tool_id.to_string();
+ let input_id = format!("{}-{:?}-new-pattern", tool_id, rule_type);
+
+ SettingsInputField::new()
+ .with_id(input_id)
+ .with_placeholder("Add regex patternโฆ")
+ .tab_index(0)
+ .with_buffer_font()
+ .display_clear_button()
+ .display_confirm_button()
+ .on_confirm(move |pattern, _window, cx| {
+ if let Some(pattern) = pattern {
+ if !pattern.trim().is_empty() {
+ save_pattern(&tool_id_owned, rule_type, pattern.trim().to_string(), cx);
+ }
+ }
+ })
+ .into_any_element()
+}
+
+fn render_default_mode_section(
+ tool_id: &'static str,
+ current_mode: ToolPermissionMode,
+ _cx: &mut Context<SettingsWindow>,
+) -> AnyElement {
+ let mode_label = match current_mode {
+ ToolPermissionMode::Allow => "Allow",
+ ToolPermissionMode::Deny => "Deny",
+ ToolPermissionMode::Confirm => "Confirm",
+ };
+
+ let tool_id_owned = tool_id.to_string();
+
+ h_flex()
+ .justify_between()
+ .child(
+ v_flex().child(Label::new("Default Action")).child(
+ Label::new("Action to take when no patterns match.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child(
+ PopoverMenu::new(format!("default-mode-{}", tool_id))
+ .trigger(
+ Button::new(format!("mode-trigger-{}", tool_id), mode_label)
+ .tab_index(0_isize)
+ .style(ButtonStyle::Outlined)
+ .size(ButtonSize::Medium)
+ .icon(IconName::ChevronDown)
+ .icon_position(IconPosition::End)
+ .icon_size(IconSize::Small),
+ )
+ .menu(move |window, cx| {
+ let tool_id = tool_id_owned.clone();
+ Some(ContextMenu::build(window, cx, move |menu, _, _| {
+ let tool_id_confirm = tool_id.clone();
+ let tool_id_allow = tool_id.clone();
+ let tool_id_deny = tool_id;
+
+ menu.entry("Confirm", None, move |_, cx| {
+ set_default_mode(&tool_id_confirm, ToolPermissionMode::Confirm, cx);
+ })
+ .entry("Allow", None, move |_, cx| {
+ set_default_mode(&tool_id_allow, ToolPermissionMode::Allow, cx);
+ })
+ .entry("Deny", None, move |_, cx| {
+ set_default_mode(&tool_id_deny, ToolPermissionMode::Deny, cx);
+ })
+ }))
+ })
+ .anchor(gpui::Corner::TopRight),
+ )
+ .into_any_element()
+}
+
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub(crate) enum RuleType {
+ Allow,
+ Deny,
+ Confirm,
+}
+
+struct ToolRulesView {
+ default_mode: ToolPermissionMode,
+ always_allow: Vec<String>,
+ always_deny: Vec<String>,
+ always_confirm: Vec<String>,
+}
+
+fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
+ let settings = AgentSettings::get_global(cx);
+
+ let tool_rules = settings.tool_permissions.tools.get(tool_name);
+
+ match tool_rules {
+ Some(rules) => ToolRulesView {
+ default_mode: rules.default_mode,
+ always_allow: rules
+ .always_allow
+ .iter()
+ .map(|r| r.pattern.clone())
+ .collect(),
+ always_deny: rules
+ .always_deny
+ .iter()
+ .map(|r| r.pattern.clone())
+ .collect(),
+ always_confirm: rules
+ .always_confirm
+ .iter()
+ .map(|r| r.pattern.clone())
+ .collect(),
+ },
+ None => ToolRulesView {
+ default_mode: ToolPermissionMode::Confirm,
+ always_allow: Vec::new(),
+ always_deny: Vec::new(),
+ always_confirm: Vec::new(),
+ },
+ }
+}
+
+fn save_pattern(tool_name: &str, rule_type: RuleType, pattern: String, cx: &mut App) {
+ let tool_name = tool_name.to_string();
+
+ SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
+ let tool_permissions = settings
+ .agent
+ .get_or_insert_default()
+ .tool_permissions
+ .get_or_insert_default();
+ let tool_rules = tool_permissions
+ .tools
+ .entry(Arc::from(tool_name.as_str()))
+ .or_default();
+
+ let rule = settings::ToolRegexRule {
+ pattern,
+ case_sensitive: None,
+ };
+
+ let rules_list = match rule_type {
+ RuleType::Allow => tool_rules.always_allow.get_or_insert_default(),
+ RuleType::Deny => tool_rules.always_deny.get_or_insert_default(),
+ RuleType::Confirm => tool_rules.always_confirm.get_or_insert_default(),
+ };
+
+ if !rules_list.0.iter().any(|r| r.pattern == rule.pattern) {
+ rules_list.0.push(rule);
+ }
+ });
+}
+
+fn update_pattern(
+ tool_name: &str,
+ rule_type: RuleType,
+ old_pattern: &str,
+ new_pattern: String,
+ cx: &mut App,
+) {
+ let tool_name = tool_name.to_string();
+ let old_pattern = old_pattern.to_string();
+
+ SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
+ let tool_permissions = settings
+ .agent
+ .get_or_insert_default()
+ .tool_permissions
+ .get_or_insert_default();
+
+ if let Some(tool_rules) = tool_permissions.tools.get_mut(tool_name.as_str()) {
+ let rules_list = match rule_type {
+ RuleType::Allow => &mut tool_rules.always_allow,
+ RuleType::Deny => &mut tool_rules.always_deny,
+ RuleType::Confirm => &mut tool_rules.always_confirm,
+ };
+
+ if let Some(list) = rules_list {
+ if let Some(rule) = list.0.iter_mut().find(|r| r.pattern == old_pattern) {
+ rule.pattern = new_pattern;
+ }
+ }
+ }
+ });
+}
+
+fn delete_pattern(tool_name: &str, rule_type: RuleType, pattern: &str, cx: &mut App) {
+ let tool_name = tool_name.to_string();
+ let pattern = pattern.to_string();
+
+ SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
+ let tool_permissions = settings
+ .agent
+ .get_or_insert_default()
+ .tool_permissions
+ .get_or_insert_default();
+
+ if let Some(tool_rules) = tool_permissions.tools.get_mut(tool_name.as_str()) {
+ let rules_list = match rule_type {
+ RuleType::Allow => &mut tool_rules.always_allow,
+ RuleType::Deny => &mut tool_rules.always_deny,
+ RuleType::Confirm => &mut tool_rules.always_confirm,
+ };
+
+ if let Some(list) = rules_list {
+ list.0.retain(|r| r.pattern != pattern);
+ }
+ }
+ });
+}
+
+fn set_default_mode(tool_name: &str, mode: ToolPermissionMode, cx: &mut App) {
+ let tool_name = tool_name.to_string();
+
+ SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
+ let tool_permissions = settings
+ .agent
+ .get_or_insert_default()
+ .tool_permissions
+ .get_or_insert_default();
+ let tool_rules = tool_permissions
+ .tools
+ .entry(Arc::from(tool_name.as_str()))
+ .or_default();
+ tool_rules.default_mode = Some(mode);
+ });
+}
+
+// Macro to generate render functions for each tool
+macro_rules! tool_config_page_fn {
+ ($fn_name:ident, $tool_id:literal) => {
+ pub fn $fn_name(
+ settings_window: &SettingsWindow,
+ scroll_handle: &ScrollHandle,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+ ) -> AnyElement {
+ render_tool_config_page($tool_id, settings_window, scroll_handle, window, cx)
+ }
+ };
+}
+
+tool_config_page_fn!(render_terminal_tool_config, "terminal");
+tool_config_page_fn!(render_edit_file_tool_config, "edit_file");
+tool_config_page_fn!(render_delete_path_tool_config, "delete_path");
+tool_config_page_fn!(render_move_path_tool_config, "move_path");
+tool_config_page_fn!(render_create_directory_tool_config, "create_directory");
+tool_config_page_fn!(render_save_file_tool_config, "save_file");
+tool_config_page_fn!(render_fetch_tool_config, "fetch");
+tool_config_page_fn!(render_web_search_tool_config, "web_search");
@@ -1,6 +1,6 @@
mod components;
mod page_data;
-mod pages;
+pub mod pages;
use anyhow::{Context as _, Result};
use editor::{Editor, EditorEvent};
@@ -3493,6 +3493,105 @@ impl SettingsWindow {
cx.notify();
}
+ /// Push a dynamically-created sub-page with a custom render function.
+ /// This is useful for nested sub-pages that aren't defined in the main pages list.
+ pub fn push_dynamic_sub_page(
+ &mut self,
+ title: impl Into<SharedString>,
+ section_header: impl Into<SharedString>,
+ json_path: Option<&'static str>,
+ render: fn(
+ &SettingsWindow,
+ &ScrollHandle,
+ &mut Window,
+ &mut Context<SettingsWindow>,
+ ) -> AnyElement,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+ ) {
+ let sub_page_link = SubPageLink {
+ title: title.into(),
+ r#type: SubPageType::default(),
+ description: None,
+ json_path,
+ in_json: true,
+ files: USER,
+ render,
+ };
+ self.push_sub_page(sub_page_link, section_header.into(), window, cx);
+ }
+
+ /// Navigate to a sub-page by its json_path.
+ /// Returns true if the sub-page was found and pushed, false otherwise.
+ pub fn navigate_to_sub_page(
+ &mut self,
+ json_path: &str,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+ ) -> bool {
+ for page in &self.pages {
+ for (item_index, item) in page.items.iter().enumerate() {
+ if let SettingsPageItem::SubPageLink(sub_page_link) = item {
+ if sub_page_link.json_path == Some(json_path) {
+ let section_header = page
+ .items
+ .iter()
+ .take(item_index)
+ .rev()
+ .find_map(|item| item.header_text().map(SharedString::new_static))
+ .unwrap_or_else(|| "Settings".into());
+
+ self.push_sub_page(sub_page_link.clone(), section_header, window, cx);
+ return true;
+ }
+ }
+ }
+ }
+ false
+ }
+
+ /// Navigate to a setting by its json_path.
+ /// Clears the sub-page stack and scrolls to the setting item.
+ /// Returns true if the setting was found, false otherwise.
+ pub fn navigate_to_setting(
+ &mut self,
+ json_path: &str,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindow>,
+ ) -> bool {
+ self.sub_page_stack.clear();
+
+ for (page_index, page) in self.pages.iter().enumerate() {
+ for (item_index, item) in page.items.iter().enumerate() {
+ let item_json_path = match item {
+ SettingsPageItem::SettingItem(setting_item) => setting_item.field.json_path(),
+ SettingsPageItem::DynamicItem(dynamic_item) => {
+ dynamic_item.discriminant.field.json_path()
+ }
+ _ => None,
+ };
+ if item_json_path == Some(json_path) {
+ if let Some(navbar_entry_index) = self
+ .navbar_entries
+ .iter()
+ .position(|e| e.page_index == page_index && e.is_root)
+ {
+ self.open_and_scroll_to_navbar_entry(
+ navbar_entry_index,
+ None,
+ false,
+ window,
+ cx,
+ );
+ self.scroll_to_content_item(item_index, window, cx);
+ return true;
+ }
+ }
+ }
+ }
+ false
+ }
+
fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
self.sub_page_stack.pop();
self.content_focus_handle.focus_handle(cx).focus(window, cx);
@@ -70,6 +70,7 @@ http_client = { workspace = true, features = ["test-support"] }
notifications = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
+remote = { workspace = true, features = ["test-support"] }
rpc = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
tree-sitter-md.workspace = true
@@ -25,6 +25,7 @@ pub struct Banner {
severity: Severity,
children: Vec<AnyElement>,
action_slot: Option<AnyElement>,
+ wrap_content: bool,
}
impl Banner {
@@ -34,6 +35,7 @@ impl Banner {
severity: Severity::Info,
children: Vec::new(),
action_slot: None,
+ wrap_content: false,
}
}
@@ -48,6 +50,12 @@ impl Banner {
self.action_slot = Some(element.into_any_element());
self
}
+
+ /// Sets whether the banner content should wrap.
+ pub fn wrap_content(mut self, wrap: bool) -> Self {
+ self.wrap_content = wrap;
+ self
+ }
}
impl ParentElement for Banner {
@@ -61,7 +69,7 @@ impl RenderOnce for Banner {
let banner = h_flex()
.py_0p5()
.gap_1p5()
- .flex_wrap()
+ .when(self.wrap_content, |this| this.flex_wrap())
.justify_between()
.rounded_sm()
.border_1();
@@ -98,6 +106,7 @@ impl RenderOnce for Banner {
let icon_and_child = h_flex()
.items_start()
.min_w_0()
+ .flex_1()
.gap_1p5()
.child(
h_flex()
@@ -105,7 +114,7 @@ impl RenderOnce for Banner {
.flex_shrink_0()
.child(Icon::new(icon).size(IconSize::XSmall).color(icon_color)),
)
- .child(div().min_w_0().children(self.children));
+ .child(div().min_w_0().flex_1().children(self.children));
if let Some(action_slot) = self.action_slot {
banner = banner
@@ -164,6 +164,11 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
// Create AppState using the test initialization
let app_state = cx.update(|cx| init_app_state(cx));
+ // Set the global app state so settings_ui and other subsystems can find it
+ cx.update(|cx| {
+ AppState::set_global(Arc::downgrade(&app_state), cx);
+ });
+
// Initialize all Zed subsystems
cx.update(|cx| {
gpui_tokio::init(cx);
@@ -187,6 +192,17 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
git_ui::init(cx);
settings_ui::init(cx);
+ // Initialize agent_ui (needed for agent thread tests)
+ let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
+ agent_ui::init(
+ app_state.fs.clone(),
+ app_state.client.clone(),
+ prompt_builder,
+ app_state.languages.clone(),
+ true, // is_eval - skip language model settings initialization
+ cx,
+ );
+
// Load default keymaps so tooltips can show keybindings like "f9" for ToggleBreakpoint
// We load a minimal set of editor keybindings needed for visual tests
cx.bind_keys([KeyBinding::new(
@@ -483,8 +499,25 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
- // Run Test 7: Settings UI sub-page auto-open visual tests
- println!("\n--- Test 7: settings_ui_subpage_auto_open (2 variants) ---");
+ // Run Test 7: Tool Permissions Settings UI visual test
+ println!("\n--- Test 7: tool_permissions_settings ---");
+ match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) {
+ Ok(TestResult::Passed) => {
+ println!("โ tool_permissions_settings: PASSED");
+ passed += 1;
+ }
+ Ok(TestResult::BaselineUpdated(_)) => {
+ println!("โ tool_permissions_settings: Baselines updated");
+ updated += 1;
+ }
+ Err(e) => {
+ eprintln!("โ tool_permissions_settings: FAILED - {}", e);
+ failed += 1;
+ }
+ }
+
+ // Run Test 8: Settings UI sub-page auto-open visual tests
+ println!("\n--- Test 8: settings_ui_subpage_auto_open (2 variants) ---");
match run_settings_ui_subpage_visual_tests(app_state.clone(), &mut cx, update_baseline) {
Ok(TestResult::Passed) => {
println!("โ settings_ui_subpage_auto_open: PASSED");
@@ -2515,3 +2548,213 @@ fn run_agent_thread_view_test(
}
}
}
+
+/// Visual test for the Tool Permissions Settings UI page
+///
+/// Takes two screenshots:
+/// 1. The settings page showing the "Configure Tool Rules" item
+/// 2. The tool permissions sub-page after clicking Configure
+#[cfg(target_os = "macos")]
+fn run_tool_permissions_visual_tests(
+ app_state: Arc<AppState>,
+ cx: &mut VisualTestAppContext,
+ _update_baseline: bool,
+) -> Result<TestResult> {
+ use zed_actions::OpenSettingsAt;
+
+ // Create a minimal workspace to dispatch the settings action from
+ let window_size = size(px(900.0), px(700.0));
+ let bounds = Bounds {
+ origin: point(px(0.0), px(0.0)),
+ size: window_size,
+ };
+
+ let project = cx.update(|cx| {
+ project::Project::local(
+ app_state.client.clone(),
+ app_state.node_runtime.clone(),
+ app_state.user_store.clone(),
+ app_state.languages.clone(),
+ app_state.fs.clone(),
+ None,
+ project::LocalProjectFlags {
+ init_worktree_trust: false,
+ ..Default::default()
+ },
+ cx,
+ )
+ });
+
+ let workspace_window: WindowHandle<Workspace> = cx
+ .update(|cx| {
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ focus: false,
+ show: false,
+ ..Default::default()
+ },
+ |window, cx| {
+ cx.new(|cx| {
+ Workspace::new(None, project.clone(), app_state.clone(), window, cx)
+ })
+ },
+ )
+ })
+ .context("Failed to open workspace window for settings test")?;
+
+ cx.run_until_parked();
+
+ // Dispatch the OpenSettingsAt action to open settings at the tool_permissions path
+ workspace_window
+ .update(cx, |_workspace, window, cx| {
+ window.dispatch_action(
+ Box::new(OpenSettingsAt {
+ path: "agent.tool_permissions".to_string(),
+ }),
+ cx,
+ );
+ })
+ .context("Failed to dispatch OpenSettingsAt action")?;
+
+ cx.run_until_parked();
+
+ // Give the settings window time to open and render
+ for _ in 0..10 {
+ cx.advance_clock(Duration::from_millis(50));
+ cx.run_until_parked();
+ }
+
+ // Find the settings window - it should be the newest window (last in the list)
+ let all_windows = cx.update(|cx| cx.windows());
+ let settings_window = all_windows.last().copied().context("No windows found")?;
+
+ // Save screenshot 1: Settings page showing "Configure Tool Rules" item
+ let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
+ .unwrap_or_else(|_| "target/visual_tests".to_string());
+ std::fs::create_dir_all(&output_dir).ok();
+
+ cx.update_window(settings_window, |_, window, _cx| {
+ window.refresh();
+ })
+ .ok();
+ cx.run_until_parked();
+
+ let output_path = PathBuf::from(&output_dir).join("tool_permissions_settings.png");
+ if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
+ let _: Result<(), _> = screenshot.save(&output_path);
+ println!("Screenshot 1 saved to: {}", output_path.display());
+ }
+
+ // Navigate to the tool permissions sub-page using the public API
+ let settings_window_handle = settings_window
+ .downcast::<settings_ui::SettingsWindow>()
+ .context("Failed to downcast to SettingsWindow")?;
+
+ settings_window_handle
+ .update(cx, |settings_window, window, cx| {
+ settings_window.navigate_to_sub_page("agent.tool_permissions", window, cx);
+ })
+ .context("Failed to navigate to tool permissions sub-page")?;
+
+ cx.run_until_parked();
+
+ // Give the sub-page time to render
+ for _ in 0..10 {
+ cx.advance_clock(Duration::from_millis(50));
+ cx.run_until_parked();
+ }
+
+ // Refresh and redraw
+ cx.update_window(settings_window, |_, window, cx| {
+ window.draw(cx).clear();
+ })
+ .ok();
+ cx.run_until_parked();
+
+ cx.update_window(settings_window, |_, window, _cx| {
+ window.refresh();
+ })
+ .ok();
+ cx.run_until_parked();
+
+ // Save screenshot 2: The tool permissions sub-page (list of tools)
+ let subpage_output_path = PathBuf::from(&output_dir).join("tool_permissions_subpage.png");
+
+ if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
+ let _: Result<(), _> = screenshot.save(&subpage_output_path);
+ println!(
+ "Screenshot 2 (tool list) saved to: {}",
+ subpage_output_path.display()
+ );
+ }
+
+ // Now navigate into a specific tool (Terminal) to show the tool config page
+ // We need to use push_dynamic_sub_page since the tool pages are nested
+ settings_window_handle
+ .update(cx, |settings_window, window, cx| {
+ settings_window.push_dynamic_sub_page(
+ "Terminal",
+ "Configure Tool Rules",
+ None,
+ settings_ui::pages::render_terminal_tool_config,
+ window,
+ cx,
+ );
+ })
+ .context("Failed to navigate to Terminal tool config")?;
+
+ cx.run_until_parked();
+
+ // Give the tool config page time to render
+ for _ in 0..10 {
+ cx.advance_clock(Duration::from_millis(50));
+ cx.run_until_parked();
+ }
+
+ // Refresh and redraw
+ cx.update_window(settings_window, |_, window, cx| {
+ window.draw(cx).clear();
+ })
+ .ok();
+ cx.run_until_parked();
+
+ cx.update_window(settings_window, |_, window, _cx| {
+ window.refresh();
+ })
+ .ok();
+ cx.run_until_parked();
+
+ // Save screenshot 3: Individual tool config page
+ let tool_config_output_path =
+ PathBuf::from(&output_dir).join("tool_permissions_tool_config.png");
+
+ if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
+ let _: Result<(), _> = screenshot.save(&tool_config_output_path);
+ println!(
+ "Screenshot 3 (tool config) saved to: {}",
+ tool_config_output_path.display()
+ );
+ }
+
+ // Clean up - close the settings window
+ let _ = cx.update_window(settings_window, |_, window, _cx| {
+ window.remove_window();
+ });
+
+ // Close the workspace window
+ let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
+ window.remove_window();
+ });
+
+ cx.run_until_parked();
+
+ // Give background tasks time to finish
+ for _ in 0..5 {
+ cx.advance_clock(Duration::from_millis(100));
+ cx.run_until_parked();
+ }
+
+ // Return success - we're just capturing screenshots, not comparing baselines
+ Ok(TestResult::Passed)
+}