settings ui: Add page for AI tool permissions (#48277)

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>

Change summary

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(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -15197,6 +15197,8 @@ dependencies = [
 name = "settings_ui"
 version = "0.1.0"
 dependencies = [
+ "agent",
+ "agent_settings",
  "anyhow",
  "assets",
  "bm25",

crates/settings_ui/Cargo.toml ๐Ÿ”—

@@ -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

crates/settings_ui/src/components/input_field.rs ๐Ÿ”—

@@ -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| {

crates/settings_ui/src/page_data.rs ๐Ÿ”—

@@ -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.",

crates/settings_ui/src/pages.rs ๐Ÿ”—

@@ -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,
+};

crates/settings_ui/src/pages/tool_permissions_setup.rs ๐Ÿ”—

@@ -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, &current_text, cx);
+        let decision = evaluate_test_input(tool_id, &current_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");

crates/settings_ui/src/settings_ui.rs ๐Ÿ”—

@@ -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);

crates/title_bar/Cargo.toml ๐Ÿ”—

@@ -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

crates/ui/src/components/banner.rs ๐Ÿ”—

@@ -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

crates/zed/src/visual_test_runner.rs ๐Ÿ”—

@@ -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)
+}