From fb40850c749e725ff24b8dc6a4fc75789389d35c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 4 Feb 2026 03:42:35 -0300 Subject: [PATCH] settings ui: Add page for AI tool permissions (#48277) 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 --- Cargo.lock | 2 + crates/settings_ui/Cargo.toml | 3 + .../settings_ui/src/components/input_field.rs | 178 +++- crates/settings_ui/src/page_data.rs | 13 +- crates/settings_ui/src/pages.rs | 9 + .../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, 1462 insertions(+), 28 deletions(-) create mode 100644 crates/settings_ui/src/pages/tool_permissions_setup.rs diff --git a/Cargo.lock b/Cargo.lock index 0587c5a0a45c448f6f340ba4ff1cbe9502cb54ad..23098e0af83beea04edf20ef11ba62847317aa95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15197,6 +15197,8 @@ dependencies = [ name = "settings_ui" version = "0.1.0" dependencies = [ + "agent", + "agent_settings", "anyhow", "assets", "bm25", diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 81fb7503697f33c4aa3716807369a5327c32e114..717099f1938eb6317aa8ba9372bd7765d98aeacd 100644 --- a/crates/settings_ui/Cargo.toml +++ b/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 diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index 17afe89029e10f3df469426f390ead21c91e6b42..0f381dbcf03eb4ef4a0b9187a9a62e4bf6b2e260 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/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, initial_text: Option, placeholder: Option<&'static str>, - confirm: Option, &mut Window, &mut App)>>, + confirm: Option, &mut Window, &mut App)>>, tab_index: Option, + use_buffer_font: bool, + display_confirm_button: bool, + display_clear_button: bool, + action_slot: Option, + color: Option, } -// 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) -> 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, &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::({ move |_, window, cx| { diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index a503eb8dd11d2ba6ef2695cdbef90388a791cf86..b358a7dd202eb9e003169d14803781813ea9163e 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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.", diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index cda63c6f0db5297333d2c371ffe75905d89a59fb..3ea489abe0801fad6ce5666cc5543230b09add83 100644 --- a/crates/settings_ui/src/pages.rs +++ b/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, +}; diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs new file mode 100644 index 0000000000000000000000000000000000000000..7e14770810eeab7fa638078af270981c20c3d07f --- /dev/null +++ b/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, +) -> AnyElement { + let tool_items: Vec = 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 = 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, +) -> 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) -> 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, +) -> 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) -> 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, +) -> 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 { + 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, +) -> 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) -> 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, +) -> 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, +) -> 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, +) -> 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, + always_deny: Vec, + always_confirm: Vec, +} + +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(::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(::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(::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(::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, + ) -> 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"); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 306e229ae89ac069c0ba261940f5b7b0c1597ff6..41f57405622ecf8279f047a6bf1f45540b765d28 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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, + section_header: impl Into, + json_path: Option<&'static str>, + render: fn( + &SettingsWindow, + &ScrollHandle, + &mut Window, + &mut Context, + ) -> AnyElement, + window: &mut Window, + cx: &mut Context, + ) { + 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, + ) -> 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, + ) -> 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) { self.sub_page_stack.pop(); self.content_focus_handle.focus_handle(cx).focus(window, cx); diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index b322d2c51e7886f95ba82bfe2830067425607f8c..40c6ba6ae60ef06cab84c8be35150f0bccc748f8 100644 --- a/crates/title_bar/Cargo.toml +++ b/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 diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index a09170f5b2693d26b2c9f3ef539e1a70d391615a..0d7b92ea3d20e2348e68d0ff0ba2ff3b88d99012 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -25,6 +25,7 @@ pub struct Banner { severity: Severity, children: Vec, action_slot: Option, + 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 diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 0e48eed394d89effc0ace1032d6d309bacfbcd07..b092da8027b5f023e634033b2a2498dba2adcceb 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/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, + cx: &mut VisualTestAppContext, + _update_baseline: bool, +) -> Result { + 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 = 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::() + .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) +}