From b7431146c89361e3853bc156ae9234e687dbfa67 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 13:12:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=AC=EB=9E=98=EC=8B=9C=20=ED=83=90?= =?UTF-8?q?=EC=83=89=20=EC=A0=95=ED=95=A9=ED=99=94=EC=99=80=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=9A=94=EC=B2=AD=EC=B0=BD=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=EB=A5=BC=20=EB=B0=98=EC=98=81=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EA=B0=9C=EB=B0=9C=EB=AC=B8=EC=84=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=EC=9D=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatWindow: slash palette 이동 기준을 렌더 순서로 통일해 방향키/휠/Home/End 선택 체감 불일치를 해소 - ChatWindow: 최근 권한 거부 카드 액션 순서를 활용하지 않음→소극 활용→적극 활용→예외 해제로 정렬 - ChatWindow: /permissions, /allowed-tools 사용법 표기를 deny→default→acceptedits→plan→bypass→dontask→status 순서로 통일 - PermissionRequestWindow: 권한 선택/위험도/미리보기/명령 위험도 문구를 한국어 중심으로 정리하고 깨진 문자열을 복구 - README.md: 업데이트 시각을 2026-04-04 13:07(KST)로 갱신하고 이번 반영 항목 2건 추가 - docs/DEVELOPMENT.md: 연속 실행 25차 이력(슬래시 정합화, 권한 다이얼로그 복구, 품질 게이트 결과) 추가 - 검증: dotnet build 경고 0/오류 0, 슬래시/운영모드 필터 테스트 43건 통과 --- README.md | 4 +- docs/DEVELOPMENT.md | 22 + src/AxCopilot/Views/ChatWindow.xaml.cs | 67 +- .../Views/PermissionRequestWindow.cs | 1171 +++++++++++++++++ 4 files changed, 1228 insertions(+), 36 deletions(-) create mode 100644 src/AxCopilot/Views/PermissionRequestWindow.cs diff --git a/README.md b/README.md index 776d52b..883b7a7 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ public class MyHandler : IActionHandler ### v0.7.3 — AX Agent 권한 코어 재구성 + 입력 계층 정리 -업데이트: 2026-04-04 12:41 (KST) +업데이트: 2026-04-04 13:07 (KST) | 분류 | 내용 | |------|------| @@ -273,6 +273,8 @@ public class MyHandler : IActionHandler | 권한 상태 표시 간소화 | 권한 상태 텍스트(`/permissions`, `/allowed-tools`)를 운영 모드 포함 축약형으로 정리하고 권한 버튼 툴팁에 동일 정보를 반영 | | 설정창 외부 진입 안정화 | AX Agent 설정창 오픈 시 리소스 병합 실패를 방어하고, 외부 진입 경로를 Dispatcher 기반으로 안정화 | | 모델 라벨 반응형 보강 | 컴포저 상단 모델 라벨에 말줄임(`MaxWidth` + `CharacterEllipsis`)을 적용해 좁은 폭에서 레이아웃 깨짐을 방지 | +| 슬래시 탐색 순서 정합화 | `/` 팝업의 방향키/휠/Home/End 이동 기준을 렌더 순서(핀/최근 정렬 적용 순서)로 통일해 스크롤·선택 체감 불일치를 해소 | +| 권한 요청창 한국어/인코딩 복구 | `PermissionRequestWindow`의 깨진 문자열을 복구하고 권한 선택/위험도/미리보기 문구를 한국어 기준으로 정리 | | Slash palette 상태 분리 시작 | `ChatWindow`에 몰려 있던 slash 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편 기반 마련 | | 런처 이미지 미리보기 추가 | `#` 클립보드 이미지 항목에서 `Shift+Enter`로 전용 미리보기 창을 열고, 줌·원본 해상도 확인·PNG/JPEG/BMP 저장·클립보드 복사를 지원 | | 검증 | `dotnet build` 경고 0 / 오류 0, `dotnet test` 436 passed / 0 failed | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 80105e7..e6cedb7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3282,3 +3282,25 @@ else: +## 2026-04-04 추가 진행 기록 (연속 실행 25차: 슬래시 탐색 정합화 + 권한 요청창 문구 복구) + +업데이트: 2026-04-04 13:07 (KST) + +### 1) slash palette 탐색 정합화 +- ChatWindow에 렌더 순서 전용 인덱스(_slashVisibleAbsoluteOrder)를 추가. +- / 팝업 렌더 시 실제 표시 순서를 기록하고, 방향키/휠 이동이 원본 목록 인덱스가 아닌 렌더 순서를 기준으로 동작하도록 보정. +- Home/End 키도 동일 기준(현재 화면에 표시된 첫/마지막 항목)으로 이동하도록 통일. + +### 2) 권한 액션/표기 일관성 보강 +- 최근 권한 거부 카드의 빠른 액션 순서를 활용하지 않음 → 소극 활용 → 적극 활용 → 예외 해제로 정렬. +- /permissions, /allowed-tools 사용법 표기를 동일 순서(deny|default|acceptedits|plan|bypass|dontask|status)로 정리. + +### 3) 권한 요청 다이얼로그 한국어/인코딩 복구 +- PermissionRequestWindow의 옵션/도움말/헤더/위험도/미리보기 문구를 한국어 기준으로 통일. +- 깨진 문자열(권한 확인/작업 허용/위험도 라벨) 복구. +- 파일 요약 문구에서 인코딩 손상 텍스트를 정리(존재함 · 용량 · 수정 시각). +- 명령 위험도 분류 문구를 한국어로 변환. + +### 4) 품질 게이트 +- dotnet build src/AxCopilot/AxCopilot.csproj 통과 (경고 0, 오류 0). +- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj --no-build --filter "FullyQualifiedName~ChatWindowSlashPolicyTests|FullyQualifiedName~OperationModeReadinessTests" 통과 (43 passed, 0 failed). diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 5d2620d..5416403 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1882,9 +1882,9 @@ public partial class ChatWindow : Window return button; } + actionRow.Children.Add(CreateActionButton("활용하지 않음", "#FEF2F2", "#991B1B", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny))); actionRow.Children.Add(CreateActionButton("소극 활용", "#EEF2FF", "#1D4ED8", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default))); actionRow.Children.Add(CreateActionButton("적극 활용", "#ECFDF5", "#166534", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits))); - actionRow.Children.Add(CreateActionButton("활용하지 않음", "#FEF2F2", "#991B1B", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny))); actionRow.Children.Add(CreateActionButton("예외 해제", "#F3F4F6", "#374151", () => SetToolPermissionOverride(latestDenied.ToolName!, null))); deniedStack.Children.Add(actionRow); } @@ -5033,6 +5033,7 @@ public partial class ChatWindow : Window // ── 슬래시 명령어 팝업 상태 ── private readonly SlashPaletteState _slashPalette = new(); private readonly Dictionary _slashVisibleItemByAbsoluteIndex = new(); + private readonly List _slashVisibleAbsoluteOrder = new(); // ── 슬래시 명령어 (탭별 분류) ── @@ -5294,11 +5295,14 @@ public partial class ChatWindow : Window : GetSlashSectionExpanded("slash_commands", true); } + private IReadOnlyList GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder; + /// 현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다. private void RenderSlashPage() { SlashItems.Items.Clear(); _slashVisibleItemByAbsoluteIndex.Clear(); + _slashVisibleAbsoluteOrder.Clear(); var total = _slashPalette.Matches.Count; var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill); var totalCommands = total - totalSkills; @@ -5535,6 +5539,7 @@ public partial class ChatWindow : Window SlashItems.Items.Add(item); _slashVisibleItemByAbsoluteIndex[absoluteIndex] = item; + _slashVisibleAbsoluteOrder.Add(absoluteIndex); } SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded)); @@ -5588,33 +5593,28 @@ public partial class ChatWindow : Window private void MoveSlashSelection(int direction) { - if (_slashPalette.Matches.Count == 0) + var visibleOrder = GetVisibleSlashOrderedIndices(); + if (visibleOrder.Count == 0) return; - if (_slashPalette.SelectedIndex < 0 || !IsSlashItemVisibleByIndex(_slashPalette.SelectedIndex)) - _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); - - if (_slashPalette.SelectedIndex < 0) + var currentPosition = -1; + for (var i = 0; i < visibleOrder.Count; i++) + { + if (visibleOrder[i] != _slashPalette.SelectedIndex) + continue; + currentPosition = i; + break; + } + if (currentPosition < 0) + { + _slashPalette.SelectedIndex = visibleOrder[0]; return; + } - if (direction < 0) - { - for (var i = _slashPalette.SelectedIndex - 1; i >= 0; i--) - { - if (!IsSlashItemVisibleByIndex(i)) continue; - _slashPalette.SelectedIndex = i; - return; - } - } - else if (direction > 0) - { - for (var i = _slashPalette.SelectedIndex + 1; i < _slashPalette.Matches.Count; i++) - { - if (!IsSlashItemVisibleByIndex(i)) continue; - _slashPalette.SelectedIndex = i; - return; - } - } + if (direction < 0 && currentPosition > 0) + _slashPalette.SelectedIndex = visibleOrder[currentPosition - 1]; + else if (direction > 0 && currentPosition < visibleOrder.Count - 1) + _slashPalette.SelectedIndex = visibleOrder[currentPosition + 1]; } /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. @@ -7349,11 +7349,11 @@ public partial class ChatWindow : Window if (permAction == "status") { - AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions default|acceptedits|plan|bypass|dontask|deny|status"); + AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions deny|default|acceptedits|plan|bypass|dontask|status"); return; } - OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions default|acceptedits|plan|bypass|dontask|deny|status"); + OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions deny|default|acceptedits|plan|bypass|dontask|status"); return; } if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal)) @@ -7367,11 +7367,11 @@ public partial class ChatWindow : Window if (toolAction == "status") { - AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools default|acceptedits|plan|bypass|dontask|deny|status"); + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools deny|default|acceptedits|plan|bypass|dontask|status"); return; } - OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools default|acceptedits|plan|bypass|dontask|deny|status"); + OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools deny|default|acceptedits|plan|bypass|dontask|status"); return; } if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal)) @@ -10961,18 +10961,15 @@ public partial class ChatWindow : Window } else if (e.Key == Key.Home) { - _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); + var visible = GetVisibleSlashOrderedIndices(); + _slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches); RenderSlashPage(); e.Handled = true; } else if (e.Key == Key.End) { - for (var i = _slashPalette.Matches.Count - 1; i >= 0; i--) - { - if (!IsSlashItemVisibleByIndex(i)) continue; - _slashPalette.SelectedIndex = i; - break; - } + var visible = GetVisibleSlashOrderedIndices(); + _slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches); RenderSlashPage(); e.Handled = true; } diff --git a/src/AxCopilot/Views/PermissionRequestWindow.cs b/src/AxCopilot/Views/PermissionRequestWindow.cs new file mode 100644 index 0000000..2fd8a0e --- /dev/null +++ b/src/AxCopilot/Views/PermissionRequestWindow.cs @@ -0,0 +1,1171 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Media.Effects; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +internal sealed class PermissionRequestWindow : Window +{ + internal enum PermissionPromptResult + { + Reject, + AllowOnce, + AllowForSession, + } + + private sealed record PermissionPromptProfile( + string HeaderIcon, + string HeaderTitle, + string Headline, + string RiskLabel, + Color RiskColor); + + private sealed record UiExpressionProfile( + string Key, + int FilePeekLineCount, + int DiffLineCount, + int PreviewMaxHeight, + bool ShowHints, + bool ShowOptionDescription); + + private PermissionPromptResult _result = PermissionPromptResult.Reject; + + private PermissionRequestWindow( + string toolName, + string target, + AgentLoopService.PermissionPromptPreview? preview) + { + Width = 580; + MinWidth = 520; + MaxWidth = 760; + SizeToContent = SizeToContent.Height; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + ResizeMode = ResizeMode.NoResize; + WindowStyle = WindowStyle.None; + AllowsTransparency = true; + Background = Brushes.Transparent; + ShowInTaskbar = false; + Topmost = true; + + var profile = ResolveProfile(toolName); + var uiProfile = ResolveUiExpressionProfile(); + + var bg = Application.Current.TryFindResource("LauncherBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)); + var primary = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondary = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var accent = Application.Current.TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var border = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); + var hoverBg = Application.Current.TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var riskBrush = new SolidColorBrush(profile.RiskColor); + + var root = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + Padding = new Thickness(22, 18, 22, 16), + Effect = new DropShadowEffect + { + BlurRadius = 24, + ShadowDepth = 6, + Opacity = 0.35, + Color = Colors.Black, + }, + }; + + var stack = new StackPanel(); + + var header = new Grid { Margin = new Thickness(0, 0, 0, 10) }; + header.MouseLeftButtonDown += (_, _) => { try { DragMove(); } catch { } }; + header.Children.Add(new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = profile.HeaderIcon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 16, + Foreground = riskBrush, + Margin = new Thickness(0, 0, 8, 0), + }, + new TextBlock + { + Text = GetHeaderTitle(profile.HeaderTitle, uiProfile), + FontSize = 14, + FontWeight = FontWeights.SemiBold, + Foreground = primary, + } + } + }); + + var close = new Border + { + Width = 28, + Height = 28, + CornerRadius = new CornerRadius(8), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + HorizontalAlignment = HorizontalAlignment.Right, + Child = new TextBlock + { + Text = "\uE8BB", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secondary, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + }; + close.MouseEnter += (s, _) => ((Border)s).Background = hoverBg; + close.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + close.MouseLeftButtonUp += (_, _) => CloseWith(PermissionPromptResult.Reject); + header.Children.Add(close); + stack.Children.Add(header); + + stack.Children.Add(new Border + { + Height = 1, + Background = border, + Opacity = 0.35, + Margin = new Thickness(0, 0, 0, 12), + }); + + stack.Children.Add(new TextBlock + { + Text = GetHeadline(profile.Headline, uiProfile), + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = primary, + Margin = new Thickness(0, 0, 0, 8), + }); + + stack.Children.Add(new Border + { + Background = new SolidColorBrush(Color.FromArgb(0x16, profile.RiskColor.R, profile.RiskColor.G, profile.RiskColor.B)), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x50, profile.RiskColor.R, profile.RiskColor.G, profile.RiskColor.B)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(10, 6, 10, 6), + Margin = new Thickness(0, 0, 0, 10), + Child = new TextBlock + { + Text = GetRiskLabel(profile.RiskLabel, uiProfile), + FontSize = 11.5, + Foreground = riskBrush, + FontWeight = FontWeights.SemiBold, + } + }); + + stack.Children.Add(BuildInfoRow("\uE943", "Tool", toolName, primary, secondary, itemBg)); + stack.Children.Add(BuildInfoRow("\uE8B7", "Target", target, primary, secondary, itemBg)); + + var previewCard = BuildTargetPreview(toolName, target, preview, primary, secondary, itemBg, border, uiProfile); + if (previewCard != null) + stack.Children.Add(previewCard); + + if (uiProfile.ShowHints && TryBuildFileHints(target, secondary, accent, out var hintRow)) + stack.Children.Add(hintRow); + + if (uiProfile.Key != "simple") + { + stack.Children.Add(new TextBlock + { + Text = "沅뚰븳 ?좏깮", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primary, + Margin = new Thickness(0, 14, 0, 6), + }); + } + + var optionList = new StackPanel(); + optionList.Children.Add(BuildOption( + icon: "\uE73E", + title: "?대쾲留??덉슜", + description: uiProfile.ShowOptionDescription ? "?꾩옱 ?붿껌 1?뚮쭔 ?덉슜?⑸땲??" : "", + fg: new SolidColorBrush(Color.FromRgb(0x05, 0x96, 0x69)), + bg: hoverBg, + onClick: () => CloseWith(PermissionPromptResult.AllowOnce))); + + optionList.Children.Add(BuildOption( + icon: "\uE8FB", + title: "?대쾲 ?ㅽ뻾 ?숈븞 ?덉슜", + description: uiProfile.ShowOptionDescription ? "?꾩옱 ?ㅽ뻾 以??숈씪 踰붿쐞 ?붿껌???먮룞 ?덉슜?⑸땲??" : "", + fg: accent, + bg: hoverBg, + onClick: () => CloseWith(PermissionPromptResult.AllowForSession))); + + optionList.Children.Add(BuildOption( + icon: "\uE711", + title: "嫄곕?", + description: uiProfile.ShowOptionDescription ? "?붿껌??李⑤떒?섍퀬 ???묒뾽 ?놁씠 怨꾩냽 吏꾪뻾?⑸땲??" : "", + fg: new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), + bg: hoverBg, + onClick: () => CloseWith(PermissionPromptResult.Reject))); + + stack.Children.Add(optionList); + stack.Children.Add(new TextBlock + { + Text = uiProfile.Key == "simple" + ? "Esc: 嫄곕?" + : "Esc: 嫄곕? | Enter: ?대쾲留??덉슜", + FontSize = 11, + Foreground = secondary, + Margin = new Thickness(2, 12, 0, 0), + }); + + root.Child = stack; + Content = root; + + PreviewKeyDown += (_, e) => + { + if (e.Key == Key.Escape) + { + e.Handled = true; + CloseWith(PermissionPromptResult.Reject); + } + else if (e.Key == Key.Enter) + { + e.Handled = true; + CloseWith(PermissionPromptResult.AllowOnce); + } + }; + + root.Opacity = 0; + root.RenderTransformOrigin = new Point(0.5, 0.5); + root.RenderTransform = new ScaleTransform(0.96, 0.96); + Loaded += (_, _) => + { + root.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(140))); + var sx = new DoubleAnimation(0.96, 1, TimeSpan.FromMilliseconds(180)) + { + EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }, + }; + var sy = new DoubleAnimation(0.96, 1, TimeSpan.FromMilliseconds(180)) + { + EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }, + }; + ((ScaleTransform)root.RenderTransform).BeginAnimation(ScaleTransform.ScaleXProperty, sx); + ((ScaleTransform)root.RenderTransform).BeginAnimation(ScaleTransform.ScaleYProperty, sy); + }; + } + + private static PermissionPromptProfile ResolveProfile(string toolName) + { + var tool = toolName?.Trim().ToLowerInvariant() ?? ""; + if (tool.Contains("file_write") || tool.Contains("file_edit") || tool.Contains("file")) + { + return new PermissionPromptProfile( + HeaderIcon: "\uE8A5", + HeaderTitle: "AX Agent ?뚯씪 沅뚰븳 ?붿껌", + Headline: "?먯씠?꾪듃媛€ ?뚯씪 蹂€寃쎌쓣 ?붿껌?덉뒿?덈떎.", + RiskLabel: "以묎컙 ?꾪뿕", + RiskColor: Color.FromRgb(0xEA, 0x58, 0x0C)); + } + + if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell")) + { + return new PermissionPromptProfile( + HeaderIcon: "\uE756", + HeaderTitle: "AX Agent 紐낅졊 ?ㅽ뻾 沅뚰븳 ?붿껌", + Headline: "?먯씠?꾪듃媛€ ??紐낅졊 ?ㅽ뻾???붿껌?덉뒿?덈떎.", + RiskLabel: "?믪? ?꾪뿕", + RiskColor: Color.FromRgb(0xDC, 0x26, 0x26)); + } + + if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) + { + return new PermissionPromptProfile( + HeaderIcon: "\uE774", + HeaderTitle: "AX Agent ?ㅽ듃?뚰겕 沅뚰븳 ?붿껌", + Headline: "?먯씠?꾪듃媛€ ?몃? 由ъ냼???묎렐???붿껌?덉뒿?덈떎.", + RiskLabel: "以묎컙 ?꾪뿕", + RiskColor: Color.FromRgb(0xD9, 0x77, 0x06)); + } + + return new PermissionPromptProfile( + HeaderIcon: "\uE897", + HeaderTitle: "AX Agent 沅뚰븳 ?붿껌", + Headline: "怨꾩냽 吏꾪뻾?섎젮硫?沅뚰븳 ?뺤씤???꾩슂?⑸땲??", + RiskLabel: "寃€???꾩슂", + RiskColor: Color.FromRgb(0x25, 0x63, 0xEB)); + } + + private static UiExpressionProfile ResolveUiExpressionProfile() + { + var level = "balanced"; + if (Application.Current is App app) + level = NormalizeUiExpressionLevel(app.SettingsService?.Settings?.Llm.AgentUiExpressionLevel); + + return level switch + { + "rich" => new UiExpressionProfile( + Key: "rich", + FilePeekLineCount: 24, + DiffLineCount: 120, + PreviewMaxHeight: 240, + ShowHints: true, + ShowOptionDescription: true), + "simple" => new UiExpressionProfile( + Key: "simple", + FilePeekLineCount: 8, + DiffLineCount: 36, + PreviewMaxHeight: 140, + ShowHints: false, + ShowOptionDescription: false), + _ => new UiExpressionProfile( + Key: "balanced", + FilePeekLineCount: 16, + DiffLineCount: 80, + PreviewMaxHeight: 190, + ShowHints: true, + ShowOptionDescription: true), + }; + } + + private static bool TryBuildFileHints( + string target, + Brush secondary, + Brush accent, + out Border hintRow) + { + hintRow = new Border(); + if (string.IsNullOrWhiteSpace(target)) + return false; + + if (!System.IO.Path.IsPathRooted(target)) + return false; + + string fullPath; + try + { + fullPath = System.IO.Path.GetFullPath(target); + } + catch + { + return false; + } + + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 4, 0, 0), + }; + + panel.Children.Add(BuildMiniAction("\uE8C8", "Copy path", accent, () => + { + try { Clipboard.SetText(fullPath); } catch { } + })); + + panel.Children.Add(BuildMiniAction("\uED25", "Reveal", accent, () => + { + try { Process.Start("explorer.exe", $"/select,\"{fullPath}\""); } catch { } + })); + + panel.Children.Add(BuildMiniAction("\uE8A7", "Open", accent, () => + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = fullPath, + UseShellExecute = true, + }); + } + catch { } + })); + + hintRow = new Border + { + Child = panel, + Margin = new Thickness(0, 2, 0, 0), + ToolTip = new TextBlock + { + Text = "?€??寃쎈줈 鍮좊Ⅸ ?묒뾽", + Foreground = secondary, + } + }; + return true; + } + + private static Border? BuildTargetPreview( + string toolName, + string target, + AgentLoopService.PermissionPromptPreview? preview, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + if (preview != null) + { + if (string.Equals(preview.Kind, "file_edit", StringComparison.OrdinalIgnoreCase)) + { + return BuildFileEditPreviewCard( + preview.Title, + preview.Summary, + preview.Content, + primary, + secondary, + itemBg, + border, + uiProfile); + } + + if (string.Equals(preview.Kind, "file_write", StringComparison.OrdinalIgnoreCase)) + { + return BuildFileWriteTwoColumnPreviewCard( + preview.Title, + preview.Summary, + previousContent: preview.PreviousContent, + newContent: preview.Content, + primary, + secondary, + itemBg, + border, + uiProfile); + } + + return BuildPreviewCard( + preview.Title, + preview.Summary, + preview.Content, + primary, + secondary, + itemBg, + border, + uiProfile); + } + + if (string.IsNullOrWhiteSpace(target)) + return null; + + var tool = toolName?.Trim().ToLowerInvariant() ?? ""; + + if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell")) + return BuildCommandPreview(target, primary, secondary, itemBg, border, uiProfile); + + if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) + return BuildWebPreview(target, primary, secondary, itemBg, border, uiProfile); + + if (tool.Contains("file") || System.IO.Path.IsPathRooted(target)) + return BuildFilePreview(target, primary, secondary, itemBg, border, uiProfile); + + return null; + } + + private static Border BuildCommandPreview( + string command, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + var summary = ClassifyCommandRisk(command); + return BuildPreviewCard( + "紐낅졊 誘몃━蹂닿린", + summary, + command, + primary, + secondary, + itemBg, + border, + uiProfile); + } + + private static Border BuildWebPreview( + string urlText, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + var summary = "?€?곸쓣 ?뺤씤?????놁뒿?덈떎"; + if (Uri.TryCreate(urlText, UriKind.Absolute, out var uri)) + summary = $"{uri.Scheme}://{uri.Host}"; + + return BuildPreviewCard( + "?ㅽ듃?뚰겕 誘몃━蹂닿린", + $"?묒냽 ?€?? {summary}", + urlText, + primary, + secondary, + itemBg, + border, + uiProfile); + } + + private static Border? BuildFilePreview( + string pathText, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + string fullPath; + try + { + fullPath = System.IO.Path.GetFullPath(pathText); + } + catch + { + return null; + } + + var summary = "?뚯씪??李얠쓣 ???놁뒿?덈떎"; + var previewBody = fullPath; + + if (File.Exists(fullPath)) + { + try + { + var info = new FileInfo(fullPath); + summary = $"議댁옱??쨌 {FormatBytes(info.Length)} 쨌 ?섏젙 {info.LastWriteTime:yyyy-MM-dd HH:mm}"; + + var sb = new StringBuilder(); + sb.AppendLine(fullPath); + sb.AppendLine(new string('-', 36)); + + var lines = File.ReadLines(fullPath).Take(Math.Max(4, uiProfile.FilePeekLineCount)); + foreach (var line in lines) + { + sb.AppendLine(line.Length > 180 ? line[..180] + "..." : line); + } + + previewBody = sb.ToString().TrimEnd(); + } + catch + { + previewBody = fullPath; + } + } + + return BuildPreviewCard( + "?뚯씪 誘몃━蹂닿린", + summary, + previewBody, + primary, + secondary, + itemBg, + border, + uiProfile); + } + + private static Border BuildPreviewCard( + string title, + string summary, + string content, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + return new Border + { + Background = itemBg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 8, 0, 0), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = title, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = primary, + }, + new TextBlock + { + Text = summary, + FontSize = 11, + Foreground = secondary, + Margin = new Thickness(0, 2, 0, 6), + }, + new TextBox + { + Text = content, + IsReadOnly = true, + BorderThickness = new Thickness(0), + Background = Brushes.Transparent, + Foreground = primary, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + MaxHeight = uiProfile.PreviewMaxHeight, + TextWrapping = TextWrapping.Wrap, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + } + } + } + }; + } + + private static Border BuildFileEditPreviewCard( + string title, + string summary, + string content, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + var body = new StackPanel(); + var rendered = 0; + foreach (var rawLine in content.Split('\n')) + { + var line = rawLine.TrimEnd(); + if (string.IsNullOrWhiteSpace(line)) + continue; + if (rendered >= uiProfile.DiffLineCount) + break; + + Brush lineBrush = primary; + if (line.StartsWith("+ ", StringComparison.Ordinal)) + lineBrush = new SolidColorBrush(Color.FromRgb(0x16, 0xA3, 0x4A)); + else if (line.StartsWith("- ", StringComparison.Ordinal)) + lineBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); + else if (line.Contains(") - ", StringComparison.Ordinal) || line.Contains(") + ", StringComparison.Ordinal)) + lineBrush = new SolidColorBrush(Color.FromRgb(0x93, 0xC5, 0xFD)); + + body.Children.Add(new TextBlock + { + Text = line, + FontSize = 11, + FontFamily = new FontFamily("Consolas"), + Foreground = lineBrush, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 2), + }); + rendered++; + } + + var totalLines = content.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n') + .Count(line => !string.IsNullOrWhiteSpace(line)); + if (totalLines > rendered) + { + body.Children.Add(new TextBlock + { + Text = $"... {totalLines - rendered} more lines", + FontSize = 10.5, + Foreground = secondary, + Margin = new Thickness(0, 4, 0, 0), + }); + } + + return new Border + { + Background = itemBg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 8, 0, 0), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = title, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = primary, + }, + new TextBlock + { + Text = summary, + FontSize = 11, + Foreground = secondary, + Margin = new Thickness(0, 2, 0, 6), + }, + new ScrollViewer + { + MaxHeight = uiProfile.PreviewMaxHeight, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = body, + } + } + } + }; + } + + private static Border BuildFileWriteTwoColumnPreviewCard( + string title, + string summary, + string? previousContent, + string newContent, + Brush primary, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + var oldLines = SplitPreviewLines(previousContent ?? "(file does not exist)"); + var newLines = SplitPreviewLines(string.IsNullOrWhiteSpace(newContent) ? "(empty)" : newContent); + var (oldHighlights, newHighlights) = BuildIndexedDiffHighlights(oldLines, newLines); + + var grid = new Grid(); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(10) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var oldPanel = BuildWriteColumn( + header: previousContent == null ? "Current (missing/new file)" : "Current content", + content: previousContent ?? "(file does not exist)", + lines: oldLines, + highlights: oldHighlights, + headerBrush: new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06)), + textBrush: primary, + secondary, + itemBg, + border, + uiProfile); + Grid.SetColumn(oldPanel, 0); + grid.Children.Add(oldPanel); + + var newPanel = BuildWriteColumn( + header: "New content", + content: string.IsNullOrWhiteSpace(newContent) ? "(empty)" : newContent, + lines: newLines, + highlights: newHighlights, + headerBrush: new SolidColorBrush(Color.FromRgb(0x16, 0xA3, 0x4A)), + textBrush: primary, + secondary, + itemBg, + border, + uiProfile); + Grid.SetColumn(newPanel, 2); + grid.Children.Add(newPanel); + + return new Border + { + Background = itemBg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 8, 0, 0), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = title, + FontSize = 11.5, + FontWeight = FontWeights.SemiBold, + Foreground = primary, + }, + new TextBlock + { + Text = summary, + FontSize = 11, + Foreground = secondary, + Margin = new Thickness(0, 2, 0, 6), + }, + grid, + } + } + }; + } + + private static Border BuildWriteColumn( + string header, + string content, + IReadOnlyList lines, + IReadOnlyList highlights, + Brush headerBrush, + Brush textBrush, + Brush secondary, + Brush itemBg, + Brush border, + UiExpressionProfile uiProfile) + { + var linePanel = new StackPanel(); + var maxLines = Math.Min(lines.Count, Math.Max(8, uiProfile.DiffLineCount)); + for (int i = 0; i < maxLines; i++) + { + var isChanged = i < highlights.Count && highlights[i]; + linePanel.Children.Add(new Border + { + Background = isChanged + ? new SolidColorBrush(Color.FromArgb(0x2B, headerBrush is SolidColorBrush hs ? hs.Color.R : (byte)0x16, headerBrush is SolidColorBrush hs2 ? hs2.Color.G : (byte)0xA3, headerBrush is SolidColorBrush hs3 ? hs3.Color.B : (byte)0x4A)) + : Brushes.Transparent, + CornerRadius = new CornerRadius(4), + Margin = new Thickness(0, 0, 0, 1), + Padding = new Thickness(4, 1, 4, 1), + Child = new TextBlock + { + Text = lines[i], + FontFamily = new FontFamily("Consolas"), + FontSize = 10.8, + Foreground = textBrush, + TextWrapping = TextWrapping.Wrap, + } + }); + } + + if (lines.Count > maxLines) + { + linePanel.Children.Add(new TextBlock + { + Text = $"... {lines.Count - maxLines} more lines", + FontSize = 10, + Foreground = secondary, + Margin = new Thickness(0, 4, 0, 0), + }); + } + + return new Border + { + Background = Brushes.Transparent, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 6, 8, 6), + Child = new StackPanel + { + Children = + { + new TextBlock + { + Text = header, + FontSize = 11, + FontWeight = FontWeights.SemiBold, + Foreground = headerBrush, + }, + new Border + { + Background = itemBg, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x20, 0x80, 0x80, 0x80)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Margin = new Thickness(0, 5, 0, 0), + Padding = new Thickness(6, 4, 6, 4), + Child = new ScrollViewer + { + MaxHeight = uiProfile.PreviewMaxHeight - 20, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Content = linePanel, + } + }, + new TextBlock + { + Text = $"{content.Length} chars", + FontSize = 10, + Foreground = secondary, + Margin = new Thickness(0, 4, 0, 0), + } + } + } + }; + } + + private static List SplitPreviewLines(string content) + { + var lines = content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + var result = new List(lines.Length); + foreach (var line in lines) + result.Add(line.Length <= 220 ? line : line[..220] + "..."); + return result; + } + + private static (List oldHighlights, List newHighlights) BuildIndexedDiffHighlights( + IReadOnlyList oldLines, + IReadOnlyList newLines) + { + var max = Math.Max(oldLines.Count, newLines.Count); + var oldMarks = Enumerable.Repeat(false, oldLines.Count).ToList(); + var newMarks = Enumerable.Repeat(false, newLines.Count).ToList(); + + for (int i = 0; i < max; i++) + { + var hasOld = i < oldLines.Count; + var hasNew = i < newLines.Count; + if (hasOld && hasNew) + { + if (!string.Equals(oldLines[i], newLines[i], StringComparison.Ordinal)) + { + oldMarks[i] = true; + newMarks[i] = true; + } + } + else if (hasOld) + { + oldMarks[i] = true; + } + else if (hasNew) + { + newMarks[i] = true; + } + } + + return (oldMarks, newMarks); + } + + private static string ClassifyCommandRisk(string command) + { + var text = command.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return "鍮?紐낅졊"; + + var lower = text.ToLowerInvariant(); + if (lower.Contains("del ") || lower.Contains("remove-item") || lower.Contains("format ")) + return "??젣/?щ㎎ 媛€?μ꽦???덈뒗 紐낅졊"; + if (lower.Contains("git reset --hard") || lower.Contains("rm -rf")) + return "怨좎쐞????젣 紐낅졊"; + if (lower.Contains("curl ") || lower.Contains("invoke-webrequest") || lower.Contains("wget ")) + return "?ㅼ슫濡쒕뱶 ?먮뒗 ?먭꺽 ?묎렐???ы븿??紐낅졊"; + return "紐낅졊 ?ㅽ뻾 ?붿껌"; + } + + private static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } + + private static Border BuildMiniAction(string icon, string text, Brush fg, Action onClick) + { + var btn = new Border + { + Background = Brushes.Transparent, + BorderBrush = new SolidColorBrush(Color.FromArgb(0x35, 0x80, 0x80, 0x80)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(0, 0, 6, 0), + Cursor = Cursors.Hand, + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = + { + new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = fg, + Margin = new Thickness(0, 0, 5, 0), + }, + new TextBlock + { + Text = text, + FontSize = 11.5, + Foreground = fg, + } + } + } + }; + btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.86; + btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; + btn.MouseLeftButtonUp += (_, _) => onClick(); + return btn; + } + + private static Border BuildInfoRow( + string icon, + string label, + string value, + Brush primary, + Brush secondary, + Brush bg) + { + var row = new Grid { Margin = new Thickness(0, 0, 0, 6) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + row.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = secondary, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + }); + + var name = new TextBlock + { + Text = label, + FontSize = 12, + Foreground = secondary, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 10, 0), + }; + Grid.SetColumn(name, 1); + row.Children.Add(name); + + var valueBox = new Border + { + Background = bg, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(8, 5, 8, 5), + Child = new TextBlock + { + Text = value, + FontSize = 12, + Foreground = primary, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 460, + } + }; + Grid.SetColumn(valueBox, 2); + row.Children.Add(valueBox); + + return new Border { Child = row }; + } + + private static Border BuildOption( + string icon, + string title, + string description, + Brush fg, + Brush bg, + Action onClick) + { + var card = new Border + { + Background = Brushes.Transparent, + CornerRadius = new CornerRadius(10), + BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x80)), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 6), + Cursor = Cursors.Hand, + }; + + var body = new StackPanel { Orientation = Orientation.Horizontal }; + body.Children.Add(new TextBlock + { + Text = icon, + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, + Foreground = fg, + Margin = new Thickness(0, 1, 8, 0), + }); + + var textStack = new StackPanel(); + textStack.Children.Add(new TextBlock + { + Text = title, + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = fg, + }); + if (!string.IsNullOrWhiteSpace(description)) + { + textStack.Children.Add(new TextBlock + { + Text = description, + FontSize = 11.5, + Foreground = new SolidColorBrush(Color.FromRgb(0x94, 0xA3, 0xB8)), + TextWrapping = TextWrapping.Wrap, + MaxWidth = 470, + }); + } + body.Children.Add(textStack); + card.Child = body; + + card.MouseEnter += (s, _) => ((Border)s).Background = bg; + card.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent; + card.MouseLeftButtonUp += (_, _) => onClick(); + return card; + } + + private static string NormalizeUiExpressionLevel(string? value) + { + return (value ?? "balanced").Trim().ToLowerInvariant() switch + { + "rich" => "rich", + "simple" => "simple", + _ => "balanced", + }; + } + + private static string GetHeaderTitle(string defaultTitle, UiExpressionProfile uiProfile) + { + if (uiProfile.Key == "simple") + { + return defaultTitle.Contains("Permission", StringComparison.OrdinalIgnoreCase) + ? "권한 확인" + : defaultTitle; + } + return defaultTitle; + } + + private static string GetHeadline(string defaultHeadline, UiExpressionProfile uiProfile) + { + return uiProfile.Key switch + { + "simple" => "이 작업을 허용할까요?", + "rich" => $"{defaultHeadline} 범위를 확인한 뒤 안전하게 진행하세요.", + _ => defaultHeadline, + }; + } + + private static string GetRiskLabel(string defaultRiskLabel, UiExpressionProfile uiProfile) + { + if (uiProfile.Key == "simple") + return $"위험도: {defaultRiskLabel}"; + if (uiProfile.Key == "rich") + return $"위험도: {defaultRiskLabel} (검토 권장)"; + return $"위험도: {defaultRiskLabel}"; + } + + private void CloseWith(PermissionPromptResult result) + { + _result = result; + DialogResult = true; + Close(); + } + + internal static PermissionPromptResult Show( + Window owner, + string toolName, + string target, + AgentLoopService.PermissionPromptPreview? preview = null) + { + var dialog = new PermissionRequestWindow(toolName, target, preview) + { + Owner = owner, + }; + + if (owner.Resources.MergedDictionaries.Count > 0) + dialog.Resources.MergedDictionaries.Add(owner.Resources); + + dialog.ShowDialog(); + return dialog._result; + } +} +