변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
454 lines
20 KiB
C#
454 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
// ── A-2: PermissionItems 이벤트 위임 ──
|
|
private bool _permPanelDelegationInitialized;
|
|
|
|
private sealed class PermissionItemTag
|
|
{
|
|
public required string Level { get; init; }
|
|
public required bool IsActive { get; init; }
|
|
public required Brush SelectedBackground { get; init; }
|
|
public required Brush HoverBackground { get; init; }
|
|
}
|
|
|
|
private void InitPermissionPanelDelegation()
|
|
{
|
|
if (_permPanelDelegationInitialized || PermissionItems == null)
|
|
return;
|
|
_permPanelDelegationInitialized = true;
|
|
|
|
PermissionItems.MouseMove += PermissionItems_DelegatedMouseMove;
|
|
PermissionItems.MouseLeave += PermissionItems_DelegatedMouseLeave;
|
|
PermissionItems.PreviewMouseLeftButtonDown += PermissionItems_DelegatedLeftButtonDown;
|
|
PermissionItems.PreviewKeyDown += PermissionItems_DelegatedKeyDown;
|
|
}
|
|
|
|
private Border? _lastHoveredPermBorder;
|
|
|
|
private void PermissionItems_DelegatedMouseMove(object sender, MouseEventArgs e)
|
|
{
|
|
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
|
|
if (ReferenceEquals(border, _lastHoveredPermBorder))
|
|
return;
|
|
|
|
if (_lastHoveredPermBorder?.Tag is PermissionItemTag prevTag)
|
|
_lastHoveredPermBorder.Background = prevTag.IsActive ? prevTag.SelectedBackground : Brushes.Transparent;
|
|
_lastHoveredPermBorder = border;
|
|
|
|
if (border?.Tag is PermissionItemTag tag)
|
|
border.Background = tag.IsActive ? tag.SelectedBackground : tag.HoverBackground;
|
|
}
|
|
|
|
private void PermissionItems_DelegatedMouseLeave(object sender, MouseEventArgs e)
|
|
{
|
|
if (_lastHoveredPermBorder?.Tag is PermissionItemTag prevTag)
|
|
_lastHoveredPermBorder.Background = prevTag.IsActive ? prevTag.SelectedBackground : Brushes.Transparent;
|
|
_lastHoveredPermBorder = null;
|
|
}
|
|
|
|
private void PermissionItems_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
|
|
{
|
|
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
|
|
if (border?.Tag is PermissionItemTag tag)
|
|
{
|
|
e.Handled = true;
|
|
ApplyPermissionLevel(tag.Level);
|
|
}
|
|
}
|
|
|
|
private void PermissionItems_DelegatedKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key is not (Key.Enter or Key.Space))
|
|
return;
|
|
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
|
|
if (border?.Tag is PermissionItemTag tag)
|
|
{
|
|
e.Handled = true;
|
|
ApplyPermissionLevel(tag.Level);
|
|
}
|
|
}
|
|
|
|
private void ApplyPermissionLevel(string level)
|
|
{
|
|
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(level);
|
|
ScheduleSettingsSave();
|
|
_appState.LoadFromSettings(_settings);
|
|
UpdatePermissionUI();
|
|
SaveConversationSettings();
|
|
RefreshInlineSettingsPanel();
|
|
RefreshOverlayModeButtons();
|
|
PermissionPopup.IsOpen = false;
|
|
}
|
|
|
|
private void BtnPermission_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PermissionPopup == null) return;
|
|
|
|
// Dynamically retarget popup to whichever button was clicked (FolderBar or Inline)
|
|
if (sender is UIElement clickedElement &&
|
|
(ReferenceEquals(clickedElement, BtnPermissionInline) || ReferenceEquals(clickedElement, BtnPermission)))
|
|
{
|
|
PermissionPopup.PlacementTarget = clickedElement;
|
|
}
|
|
|
|
InitPermissionPanelDelegation();
|
|
_lastHoveredPermBorder = null;
|
|
PermissionItems.Children.Clear();
|
|
|
|
ChatConversation? currentConversation;
|
|
lock (_convLock) currentConversation = _currentConversation;
|
|
var coreLevels = PermissionModePresentationCatalog.Ordered.ToList();
|
|
var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
|
|
|
void AddPermissionRows(Panel container, IEnumerable<PermissionModePresentation> levels)
|
|
{
|
|
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
|
var selectedBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
|
var selectedBorder = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#D6E4FF");
|
|
foreach (var item in levels)
|
|
{
|
|
var level = item.Mode;
|
|
var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase);
|
|
var rowBorder = new Border
|
|
{
|
|
Background = isActive ? selectedBackground : Brushes.Transparent,
|
|
BorderBrush = isActive ? selectedBorder : Brushes.Transparent,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(12),
|
|
Padding = new Thickness(10, 10, 10, 10),
|
|
Margin = new Thickness(0, 0, 0, 4),
|
|
Cursor = Cursors.Hand,
|
|
Focusable = true,
|
|
};
|
|
KeyboardNavigation.SetIsTabStop(rowBorder, true);
|
|
|
|
var row = new Grid();
|
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
|
|
row.Children.Add(new TextBlock
|
|
{
|
|
Text = item.Icon,
|
|
FontFamily = s_segoeIconFont,
|
|
FontSize = 15,
|
|
Foreground = BrushFromHex(item.ColorHex),
|
|
Margin = new Thickness(0, 0, 10, 0),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
});
|
|
|
|
var textStack = new StackPanel();
|
|
textStack.Children.Add(new TextBlock
|
|
{
|
|
Text = item.Title,
|
|
FontSize = 13.5,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
|
});
|
|
textStack.Children.Add(new TextBlock
|
|
{
|
|
Text = item.Description,
|
|
FontSize = 11.5,
|
|
Margin = new Thickness(0, 2, 0, 0),
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
LineHeight = 16,
|
|
MaxWidth = 220,
|
|
});
|
|
Grid.SetColumn(textStack, 1);
|
|
row.Children.Add(textStack);
|
|
|
|
var check = new TextBlock
|
|
{
|
|
Text = isActive ? "\uE73E" : "",
|
|
FontFamily = s_segoeIconFont,
|
|
FontSize = 12,
|
|
FontWeight = FontWeights.Bold,
|
|
Foreground = BrushFromHex("#2563EB"),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(12, 0, 0, 0),
|
|
};
|
|
Grid.SetColumn(check, 2);
|
|
row.Children.Add(check);
|
|
|
|
rowBorder.Child = row;
|
|
|
|
// A-2: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장
|
|
rowBorder.Tag = new PermissionItemTag
|
|
{
|
|
Level = level,
|
|
IsActive = isActive,
|
|
SelectedBackground = selectedBackground,
|
|
HoverBackground = hoverBackground,
|
|
};
|
|
|
|
container.Children.Add(rowBorder);
|
|
}
|
|
}
|
|
|
|
AddPermissionRows(PermissionItems, coreLevels);
|
|
|
|
PermissionPopup.IsOpen = true;
|
|
Dispatcher.BeginInvoke(() =>
|
|
{
|
|
TryFocusFirstPermissionElement(PermissionItems);
|
|
}, System.Windows.Threading.DispatcherPriority.Input);
|
|
}
|
|
|
|
private static bool TryFocusFirstPermissionElement(DependencyObject root)
|
|
{
|
|
if (root is UIElement ui && ui.Focusable && ui.IsEnabled && ui.Visibility == Visibility.Visible)
|
|
return ui.Focus();
|
|
|
|
var childCount = VisualTreeHelper.GetChildrenCount(root);
|
|
for (var i = 0; i < childCount; i++)
|
|
{
|
|
var child = VisualTreeHelper.GetChild(root, i);
|
|
if (TryFocusFirstPermissionElement(child))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void SetToolPermissionOverride(string toolName, string? mode)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(toolName)) return;
|
|
var toolPermissions = _settings.Settings.Llm.ToolPermissions ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var existingKey = toolPermissions.Keys.FirstOrDefault(x => string.Equals(x, toolName, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (string.IsNullOrWhiteSpace(mode))
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(existingKey))
|
|
toolPermissions.Remove(existingKey!);
|
|
}
|
|
else
|
|
{
|
|
toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode);
|
|
}
|
|
|
|
ScheduleSettingsSave();
|
|
_appState.LoadFromSettings(_settings);
|
|
UpdatePermissionUI();
|
|
SaveConversationSettings();
|
|
}
|
|
|
|
private void RefreshPermissionPopup()
|
|
{
|
|
if (PermissionPopup == null) return;
|
|
BtnPermission_Click(this, new RoutedEventArgs());
|
|
}
|
|
|
|
private bool GetPermissionPopupSectionExpanded(string sectionKey, bool defaultValue = false)
|
|
{
|
|
var map = _settings.Settings.Llm.PermissionPopupSections;
|
|
if (map != null && map.TryGetValue(sectionKey, out var expanded))
|
|
return expanded;
|
|
return defaultValue;
|
|
}
|
|
|
|
private void SetPermissionPopupSectionExpanded(string sectionKey, bool expanded)
|
|
{
|
|
var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
|
map[sectionKey] = expanded;
|
|
ScheduleSettingsSave();
|
|
}
|
|
|
|
/// <summary>Shift+Tab으로 권한 모드를 순환합니다 (Claude Code 스타일).</summary>
|
|
private void CyclePermissionMode()
|
|
{
|
|
var llm = _settings.Settings.Llm;
|
|
llm.FilePermission = NextPermission(llm.FilePermission);
|
|
ScheduleSettingsSave();
|
|
_appState.LoadFromSettings(_settings);
|
|
UpdatePermissionUI();
|
|
SaveConversationSettings();
|
|
RefreshInlineSettingsPanel();
|
|
|
|
// Toast 알림
|
|
var label = PermissionModeCatalog.ToDisplayLabel(llm.FilePermission);
|
|
var icon = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission) switch
|
|
{
|
|
"Plan" => "\uE769",
|
|
"AcceptEdits" => "\uE73E",
|
|
"BypassPermissions" => "\uE7BA",
|
|
"Deny" => "\uE711",
|
|
_ => "\uE8D7",
|
|
};
|
|
ShowToast(label, icon);
|
|
}
|
|
|
|
private void PlanModeBannerClose_Click(object sender, MouseButtonEventArgs e)
|
|
{
|
|
if (PlanModeBanner != null)
|
|
PlanModeBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
|
|
private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PermissionTopBanner != null)
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
|
|
private void UpdatePermissionUI()
|
|
{
|
|
if (PermissionLabel == null || PermissionIcon == null) return;
|
|
|
|
// 계획 모드 배너 기본 숨김 — Plan 분기에서만 표시
|
|
if (PlanModeBanner != null)
|
|
PlanModeBanner.Visibility = Visibility.Collapsed;
|
|
|
|
ChatConversation? currentConversation;
|
|
lock (_convLock) currentConversation = _currentConversation;
|
|
var summary = _appState.GetPermissionSummary(currentConversation);
|
|
var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
|
|
PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm);
|
|
if (PermissionLabelInline != null) PermissionLabelInline.Text = PermissionLabel.Text;
|
|
PermissionIcon.Text = perm switch
|
|
{
|
|
"AcceptEdits" => "\uE73E",
|
|
"Plan" => "\uE769",
|
|
"BypassPermissions" => "\uE7BA",
|
|
"Deny" => "\uE711",
|
|
_ => "\uE8D7",
|
|
};
|
|
if (PermissionIconInline != null) PermissionIconInline.Text = PermissionIcon.Text;
|
|
if (BtnPermission != null)
|
|
{
|
|
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
|
|
BtnPermission.ToolTip = $"{summary.Description}\n운영 모드: {operationMode}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개";
|
|
BtnPermission.Background = Brushes.Transparent;
|
|
BtnPermission.BorderThickness = new Thickness(1);
|
|
}
|
|
|
|
if (!string.Equals(_lastPermissionBannerMode, perm, StringComparison.OrdinalIgnoreCase))
|
|
_lastPermissionBannerMode = perm;
|
|
|
|
if (perm == PermissionModeCatalog.AcceptEdits)
|
|
{
|
|
var activeColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
|
|
PermissionLabel.Foreground = activeColor;
|
|
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = activeColor;
|
|
PermissionIcon.Foreground = activeColor;
|
|
if (PermissionIconInline != null) PermissionIconInline.Foreground = activeColor;
|
|
if (BtnPermission != null)
|
|
BtnPermission.BorderBrush = BrushFromHex("#86EFAC");
|
|
if (PermissionTopBanner != null)
|
|
{
|
|
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
|
|
PermissionTopBannerIcon.Text = "\uE73E";
|
|
PermissionTopBannerIcon.Foreground = activeColor;
|
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 편집 자동 승인";
|
|
PermissionTopBannerTitle.Foreground = BrushFromHex("#166534");
|
|
PermissionTopBannerText.Text = "모든 파일 편집은 자동 승인하고, 명령 실행만 계속 확인합니다.";
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
}
|
|
else if (perm == PermissionModeCatalog.Deny)
|
|
{
|
|
var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
|
|
PermissionLabel.Foreground = denyColor;
|
|
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = denyColor;
|
|
PermissionIcon.Foreground = denyColor;
|
|
if (PermissionIconInline != null) PermissionIconInline.Foreground = denyColor;
|
|
if (BtnPermission != null)
|
|
BtnPermission.BorderBrush = BrushFromHex("#86EFAC");
|
|
if (PermissionTopBanner != null)
|
|
{
|
|
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
|
|
PermissionTopBannerIcon.Text = "\uE73E";
|
|
PermissionTopBannerIcon.Foreground = denyColor;
|
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 읽기 전용";
|
|
PermissionTopBannerTitle.Foreground = denyColor;
|
|
PermissionTopBannerText.Text = "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 새 파일 생성은 가능합니다.";
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
}
|
|
else if (perm == PermissionModeCatalog.Plan)
|
|
{
|
|
var planColor = new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06));
|
|
PermissionLabel.Foreground = planColor;
|
|
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = planColor;
|
|
PermissionIcon.Foreground = planColor;
|
|
if (PermissionIconInline != null) PermissionIconInline.Foreground = planColor;
|
|
if (BtnPermission != null)
|
|
BtnPermission.BorderBrush = BrushFromHex("#FDE68A");
|
|
if (PermissionTopBanner != null)
|
|
{
|
|
PermissionTopBanner.BorderBrush = BrushFromHex("#FDE68A");
|
|
PermissionTopBannerIcon.Text = "\uE769";
|
|
PermissionTopBannerIcon.Foreground = planColor;
|
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드";
|
|
PermissionTopBannerTitle.Foreground = planColor;
|
|
PermissionTopBannerText.Text = "파일을 읽고 분석한 뒤, 실행 전에 계획을 먼저 보여줍니다.";
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
// 계획 모드 배너 표시
|
|
if (PlanModeBanner != null)
|
|
PlanModeBanner.Visibility = Visibility.Visible;
|
|
}
|
|
else if (perm == PermissionModeCatalog.BypassPermissions)
|
|
{
|
|
var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C));
|
|
PermissionLabel.Foreground = autoColor;
|
|
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = autoColor;
|
|
PermissionIcon.Foreground = autoColor;
|
|
if (PermissionIconInline != null) PermissionIconInline.Foreground = autoColor;
|
|
if (BtnPermission != null)
|
|
BtnPermission.BorderBrush = BrushFromHex("#FDBA74");
|
|
if (PermissionTopBanner != null)
|
|
{
|
|
PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74");
|
|
PermissionTopBannerIcon.Text = "\uE814";
|
|
PermissionTopBannerIcon.Foreground = autoColor;
|
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기";
|
|
PermissionTopBannerTitle.Foreground = autoColor;
|
|
PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요.";
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var defaultFg = BrushFromHex("#2563EB");
|
|
var iconFg = new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB));
|
|
PermissionLabel.Foreground = defaultFg;
|
|
if (PermissionLabelInline != null) PermissionLabelInline.Foreground = defaultFg;
|
|
PermissionIcon.Foreground = iconFg;
|
|
if (PermissionIconInline != null) PermissionIconInline.Foreground = iconFg;
|
|
if (BtnPermission != null)
|
|
BtnPermission.BorderBrush = BrushFromHex("#BFDBFE");
|
|
if (PermissionTopBanner != null)
|
|
{
|
|
if (perm == PermissionModeCatalog.Default)
|
|
{
|
|
PermissionTopBanner.BorderBrush = BrushFromHex("#BFDBFE");
|
|
PermissionTopBannerIcon.Text = "\uE8D7";
|
|
PermissionTopBannerIcon.Foreground = BrushFromHex("#1D4ED8");
|
|
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 요청";
|
|
PermissionTopBannerTitle.Foreground = BrushFromHex("#1D4ED8");
|
|
PermissionTopBannerText.Text = "변경하기 전에 항상 확인합니다.";
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
else
|
|
{
|
|
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|