diff --git a/README.md b/README.md index 759e417..e51c6ee 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 - AX Agent의 계획 승인 흐름을 더 transcript 우선 구조로 정리했습니다. 인라인 승인 카드가 기본 경로를 맡고, `계획` 버튼은 저장된 계획 요약/단계를 여는 상세 보기 역할만 하도록 분리했습니다. - 상태선과 quick strip 계산도 presentation state로 더 모았습니다. runtime badge, compact strip, quick strip의 텍스트/강조색/노출 여부를 한 번에 계산해 창 코드의 직접 분기를 줄였습니다. +- 업데이트: 2026-04-06 07:31 (KST) +- `ChatWindow.xaml.cs`에 몰려 있던 권한 팝업 렌더와 컨텍스트 사용량 카드 렌더를 별도 partial 파일로 분리했습니다. `ChatWindow.PermissionPresentation.cs`, `ChatWindow.ContextUsagePresentation.cs`를 추가해 권한 선택/권한 상태 배너/컨텍스트 사용량 hover 카드 책임을 메인 창 orchestration 코드에서 떼어냈습니다. +- 다음 단계에서 `permission / tool-result / footer` presentation catalog를 더 세밀하게 확장하기 쉽게 구조를 정리했고, 동작은 그대로 유지한 채 transcript/푸터 품질 개선 발판을 마련했습니다. + - 업데이트: 2026-04-06 00:50 (KST) - 채팅/코워크 프리셋 카드의 hover 설명 레이어를 카드 내부 오버레이 방식에서 안정적인 tooltip형 설명으로 바꿨습니다. 카드 배경/테두리만 반응하게 정리해 hover 시 반복 깜빡임을 줄였습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e9c595c..e96ac76 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,7 @@ # AX Copilot - 媛쒕컻 臾몄꽌 +- Document update: 2026-04-06 07:31 (KST) - Split permission presentation logic out of `ChatWindow.xaml.cs` into `ChatWindow.PermissionPresentation.cs`. Permission popup row construction, popup refresh, section expansion persistence, and permission banner/status styling now live in a dedicated partial instead of the main window orchestration file. +- Document update: 2026-04-06 07:31 (KST) - Split context usage card/popup rendering into `ChatWindow.ContextUsagePresentation.cs`. The Cowork/Code context usage ring, tooltip popup copy, hover close behavior, and screen-coordinate hit testing are now isolated from the rest of the chat window flow. - Document update: 2026-04-06 01:37 (KST) - Reworked AX Agent plan approval toward a more transcript-native flow. The inline decision card remains the primary approval path, while the `계획` affordance now opens the stored plan as a detail-only surface instead of acting like a required popup step. - Document update: 2026-04-06 01:37 (KST) - Expanded `OperationalStatusPresentationState` so runtime badge, compact strip, and quick-strip labels/colors/visibility are calculated together. `ChatWindow` now consumes a richer presentation model instead of branching on strip kinds and quick-strip counters independently. diff --git a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs new file mode 100644 index 0000000..a80df24 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs @@ -0,0 +1,144 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; + +namespace AxCopilot.Views; + +public partial class ChatWindow +{ + private void RefreshContextUsageVisual() + { + if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null + || TokenUsageSummaryText == null || TokenUsageHintText == null + || TokenUsageThresholdMarker == null || CompactNowLabel == null) + return; + + var showContextUsage = _activeTab is "Cowork" or "Code"; + TokenUsageCard.Visibility = showContextUsage ? Visibility.Visible : Visibility.Collapsed; + if (!showContextUsage) + { + if (TokenUsagePopup != null) + TokenUsagePopup.IsOpen = false; + return; + } + + var llm = _settings.Settings.Llm; + var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000); + var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95); + var triggerRatio = triggerPercent / 100.0; + + int messageTokens; + lock (_convLock) + messageTokens = _currentConversation?.Messages?.Count > 0 + ? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages) + : 0; + + var draftText = InputBox?.Text ?? ""; + var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4; + var currentTokens = Math.Max(0, messageTokens + draftTokens); + var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens); + + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; + Brush progressBrush = accentBrush; + string summary; + string compactLabel; + + if (usageRatio >= 1.0) + { + progressBrush = Brushes.IndianRed; + summary = "컨텍스트 한도 초과"; + compactLabel = "지금 압축"; + } + else if (usageRatio >= triggerRatio) + { + progressBrush = Brushes.DarkOrange; + summary = llm.EnableProactiveContextCompact ? "곧 자동 압축" : "압축 임계 도달"; + compactLabel = "압축 권장"; + } + else if (usageRatio >= triggerRatio * 0.7) + { + progressBrush = Brushes.Goldenrod; + summary = "컨텍스트 사용 증가"; + compactLabel = "미리 압축"; + } + else + { + summary = "컨텍스트 여유"; + compactLabel = "압축"; + } + + TokenUsageArc.Stroke = progressBrush; + TokenUsageThresholdMarker.Fill = progressBrush; + var percentText = $"{Math.Round(usageRatio * 100):0}%"; + TokenUsagePercentText.Text = percentText; + TokenUsageSummaryText.Text = $"컨텍스트 {percentText}"; + TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}"; + CompactNowLabel.Text = compactLabel; + + if (TokenUsagePopupTitle != null) + TokenUsagePopupTitle.Text = $"컨텍스트 창 {percentText}"; + if (TokenUsagePopupUsage != null) + TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}"; + if (TokenUsagePopupDetail != null) + TokenUsagePopupDetail.Text = _pendingPostCompaction ? "compact 후 첫 응답 대기 중" : $"자동 압축 시작 {triggerPercent}%"; + if (TokenUsagePopupCompact != null) + TokenUsagePopupCompact.Text = "AX Agent가 컨텍스트를 자동으로 관리합니다"; + + TokenUsageCard.ToolTip = null; + + UpdateCircularUsageArc(TokenUsageArc, usageRatio, 14, 14, 11); + PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 14, 14, 11, 2.5); + } + + private void TokenUsageCard_MouseEnter(object sender, MouseEventArgs e) + { + _tokenUsagePopupCloseTimer.Stop(); + if (TokenUsagePopup != null && TokenUsageCard?.Visibility == Visibility.Visible) + TokenUsagePopup.IsOpen = true; + } + + private void TokenUsageCard_MouseLeave(object sender, MouseEventArgs e) + { + _tokenUsagePopupCloseTimer.Stop(); + _tokenUsagePopupCloseTimer.Start(); + } + + private void TokenUsagePopup_MouseEnter(object sender, MouseEventArgs e) + { + _tokenUsagePopupCloseTimer.Stop(); + } + + private void TokenUsagePopup_MouseLeave(object sender, MouseEventArgs e) + { + _tokenUsagePopupCloseTimer.Stop(); + _tokenUsagePopupCloseTimer.Start(); + } + + private void CloseTokenUsagePopupIfIdle() + { + if (TokenUsagePopup == null) + return; + + var cardHovered = IsMouseInsideElement(TokenUsageCard); + var popupHovered = TokenUsagePopup.Child is FrameworkElement popupChild && IsMouseInsideElement(popupChild); + if (!cardHovered && !popupHovered) + TokenUsagePopup.IsOpen = false; + } + + private static bool IsMouseInsideElement(FrameworkElement? element) + { + if (element == null || !element.IsVisible || element.ActualWidth <= 0 || element.ActualHeight <= 0) + return false; + + try + { + var mouse = System.Windows.Forms.Control.MousePosition; + var point = element.PointFromScreen(new Point(mouse.X, mouse.Y)); + return point.X >= 0 && point.Y >= 0 && point.X <= element.ActualWidth && point.Y <= element.ActualHeight; + } + catch + { + return element.IsMouseOver; + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs b/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs new file mode 100644 index 0000000..3076644 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs @@ -0,0 +1,326 @@ +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 +{ + private void BtnPermission_Click(object sender, RoutedEventArgs e) + { + if (PermissionPopup == null) return; + PermissionItems.Children.Clear(); + + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + var coreLevels = PermissionModePresentationCatalog.Ordered + .Where(item => !string.Equals(item.Mode, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + + void AddPermissionRows(Panel container, IEnumerable levels) + { + foreach (var item in levels) + { + var level = item.Mode; + var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); + var rowBorder = new Border + { + Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent, + BorderBrush = 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 = new FontFamily("Segoe MDL2 Assets"), + 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 = new FontFamily("Segoe MDL2 Assets"), + 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; + rowBorder.MouseEnter += (_, _) => rowBorder.Background = BrushFromHex("#F8FAFC"); + rowBorder.MouseLeave += (_, _) => rowBorder.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent; + + var capturedLevel = level; + void ApplyPermission() + { + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); + try { _settings.Save(); } catch { } + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + RefreshOverlayModeButtons(); + PermissionPopup.IsOpen = false; + } + + rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission(); + rowBorder.KeyDown += (_, ke) => + { + if (ke.Key is Key.Enter or Key.Space) + { + ke.Handled = true; + ApplyPermission(); + } + }; + + 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(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); + } + + try { _settings.Save(); } catch { } + _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(StringComparer.OrdinalIgnoreCase); + map[sectionKey] = expanded; + try { _settings.Save(); } catch { } + } + + private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e) + { + if (PermissionTopBanner != null) + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + + private void UpdatePermissionUI() + { + if (PermissionLabel == null || PermissionIcon == null) return; + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + var summary = _appState.GetPermissionSummary(currentConversation); + var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); + PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm); + PermissionIcon.Text = perm switch + { + "AcceptEdits" => "\uE73E", + "Plan" => "\uE7C3", + "BypassPermissions" => "\uE7BA", + "Deny" => "\uE711", + _ => "\uE8D7", + }; + 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; + PermissionIcon.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; + PermissionIcon.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.BypassPermissions) + { + var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C)); + PermissionLabel.Foreground = autoColor; + PermissionIcon.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 = perm switch + { + "Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)), + _ => new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)), + }; + PermissionLabel.Foreground = defaultFg; + PermissionIcon.Foreground = iconFg; + if (BtnPermission != null) + BtnPermission.BorderBrush = perm == PermissionModeCatalog.Plan + ? BrushFromHex("#C7D2FE") + : BrushFromHex("#BFDBFE"); + if (PermissionTopBanner != null) + { + if (perm == PermissionModeCatalog.Plan) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#C7D2FE"); + PermissionTopBannerIcon.Text = "\uE7C3"; + PermissionTopBannerIcon.Foreground = BrushFromHex("#4338CA"); + PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드"; + PermissionTopBannerTitle.Foreground = BrushFromHex("#4338CA"); + PermissionTopBannerText.Text = "변경 전에 계획을 먼저 만들고 승인 흐름을 우선합니다."; + PermissionTopBanner.Visibility = Visibility.Collapsed; + } + else 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; + } + } + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index dc43f2e..1bd4e7a 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1885,325 +1885,8 @@ public partial class ChatWindow : Window // ─── 권한 메뉴 ───────────────────────────────────────────────────────── - private void BtnPermission_Click(object sender, RoutedEventArgs e) - { - if (PermissionPopup == null) return; - PermissionItems.Children.Clear(); - - ChatConversation? currentConversation; - lock (_convLock) currentConversation = _currentConversation; - var coreLevels = PermissionModePresentationCatalog.Ordered - .Where(item => !string.Equals(item.Mode, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase)) - .ToList(); - var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); - void AddPermissionRows(Panel container, IEnumerable levels) - { - foreach (var item in levels) - { - var level = item.Mode; - var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); - var rowBorder = new Border - { - Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent, - BorderBrush = 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 = new FontFamily("Segoe MDL2 Assets"), - 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 = new FontFamily("Segoe MDL2 Assets"), - 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; - rowBorder.MouseEnter += (_, _) => - { - rowBorder.Background = BrushFromHex("#F8FAFC"); - }; - rowBorder.MouseLeave += (_, _) => - { - rowBorder.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent; - }; - - var capturedLevel = level; - void ApplyPermission() - { - _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); - try { _settings.Save(); } catch { } - _appState.LoadFromSettings(_settings); - UpdatePermissionUI(); - SaveConversationSettings(); - RefreshInlineSettingsPanel(); - RefreshOverlayModeButtons(); - PermissionPopup.IsOpen = false; - } - rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission(); - rowBorder.KeyDown += (_, ke) => - { - if (ke.Key is Key.Enter or Key.Space) - { - ke.Handled = true; - ApplyPermission(); - } - }; - - container.Children.Add(rowBorder); - } - } - - AddPermissionRows(PermissionItems, coreLevels); - - PermissionPopup.IsOpen = true; - Dispatcher.BeginInvoke(() => - { - TryFocusFirstPermissionElement(PermissionItems); - }, 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(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); - } - - try { _settings.Save(); } catch { } - _appState.LoadFromSettings(_settings); - UpdatePermissionUI(); - SaveConversationSettings(); - } - - private void RefreshPermissionPopup() - { - if (PermissionPopup == null) return; - BtnPermission_Click(this, new RoutedEventArgs()); - } - private string _lastPermissionBannerMode = ""; - 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(StringComparer.OrdinalIgnoreCase); - map[sectionKey] = expanded; - try { _settings.Save(); } catch { } - } - - private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e) - { - if (PermissionTopBanner != null) - PermissionTopBanner.Visibility = Visibility.Collapsed; - } - - private void UpdatePermissionUI() - { - if (PermissionLabel == null || PermissionIcon == null) return; - ChatConversation? currentConversation; - lock (_convLock) currentConversation = _currentConversation; - var summary = _appState.GetPermissionSummary(currentConversation); - var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); - PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm); - PermissionIcon.Text = perm switch - { - "AcceptEdits" => "\uE73E", - "Plan" => "\uE7C3", - "BypassPermissions" => "\uE7BA", - "Deny" => "\uE711", - _ => "\uE8D7", - }; - 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; - PermissionIcon.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; - PermissionIcon.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.BypassPermissions) - { - var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C)); - PermissionLabel.Foreground = autoColor; - PermissionIcon.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 = perm switch - { - "Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)), - _ => new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)), - }; - PermissionLabel.Foreground = defaultFg; - PermissionIcon.Foreground = iconFg; - if (BtnPermission != null) - BtnPermission.BorderBrush = perm == PermissionModeCatalog.Plan - ? BrushFromHex("#C7D2FE") - : BrushFromHex("#BFDBFE"); - if (PermissionTopBanner != null) - { - if (perm == PermissionModeCatalog.Plan) - { - PermissionTopBanner.BorderBrush = BrushFromHex("#C7D2FE"); - PermissionTopBannerIcon.Text = "\uE7C3"; - PermissionTopBannerIcon.Foreground = BrushFromHex("#4338CA"); - PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드"; - PermissionTopBannerTitle.Foreground = BrushFromHex("#4338CA"); - PermissionTopBannerText.Text = "변경 전에 계획을 먼저 만들고 승인 흐름을 우선합니다."; - PermissionTopBanner.Visibility = Visibility.Collapsed; - } - else 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; - } - } - } - } - private bool TryApplyPermissionModeFromAction(string action, out string appliedMode) { appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); @@ -17805,147 +17488,6 @@ public partial class ChatWindow : Window _recentGitBranches.RemoveRange(6, _recentGitBranches.Count - 6); } - private void RefreshContextUsageVisual() - { - if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null - || TokenUsageSummaryText == null || TokenUsageHintText == null - || TokenUsageThresholdMarker == null || CompactNowLabel == null) - return; - - var showContextUsage = _activeTab is "Cowork" or "Code"; - TokenUsageCard.Visibility = showContextUsage ? Visibility.Visible : Visibility.Collapsed; - if (!showContextUsage) - { - if (TokenUsagePopup != null) - TokenUsagePopup.IsOpen = false; - return; - } - - var llm = _settings.Settings.Llm; - var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000); - var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95); - var triggerRatio = triggerPercent / 100.0; - - int messageTokens; - lock (_convLock) - messageTokens = _currentConversation?.Messages?.Count > 0 - ? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages) - : 0; - - var draftText = InputBox?.Text ?? ""; - var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4; - var currentTokens = Math.Max(0, messageTokens + draftTokens); - var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens); - - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; - Brush progressBrush = accentBrush; - string summary; - string compactLabel; - - if (usageRatio >= 1.0) - { - progressBrush = Brushes.IndianRed; - summary = "컨텍스트 한도 초과"; - compactLabel = "지금 압축"; - } - else if (usageRatio >= triggerRatio) - { - progressBrush = Brushes.DarkOrange; - summary = llm.EnableProactiveContextCompact ? "곧 자동 압축" : "압축 임계 도달"; - compactLabel = "압축 권장"; - } - else if (usageRatio >= triggerRatio * 0.7) - { - progressBrush = Brushes.Goldenrod; - summary = "컨텍스트 사용 증가"; - compactLabel = "미리 압축"; - } - else - { - summary = "컨텍스트 여유"; - compactLabel = "압축"; - } - - TokenUsageArc.Stroke = progressBrush; - TokenUsageThresholdMarker.Fill = progressBrush; - var percentText = $"{Math.Round(usageRatio * 100):0}%"; - TokenUsagePercentText.Text = percentText; - TokenUsageSummaryText.Text = $"컨텍스트 {percentText}"; - TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}"; - CompactNowLabel.Text = compactLabel; - - if (TokenUsagePopupTitle != null) - TokenUsagePopupTitle.Text = $"컨텍스트 창 {percentText}"; - if (TokenUsagePopupUsage != null) - TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}"; - if (TokenUsagePopupDetail != null) - { - TokenUsagePopupDetail.Text = _pendingPostCompaction - ? "compact 후 첫 응답 대기 중" - : $"자동 압축 시작 {triggerPercent}%"; - } - if (TokenUsagePopupCompact != null) - { - TokenUsagePopupCompact.Text = "AX Agent가 컨텍스트를 자동으로 관리합니다"; - } - TokenUsageCard.ToolTip = null; - - UpdateCircularUsageArc(TokenUsageArc, usageRatio, 14, 14, 11); - PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 14, 14, 11, 2.5); - } - - private void TokenUsageCard_MouseEnter(object sender, MouseEventArgs e) - { - _tokenUsagePopupCloseTimer.Stop(); - if (TokenUsagePopup != null && TokenUsageCard?.Visibility == Visibility.Visible) - TokenUsagePopup.IsOpen = true; - } - - private void TokenUsageCard_MouseLeave(object sender, MouseEventArgs e) - { - _tokenUsagePopupCloseTimer.Stop(); - _tokenUsagePopupCloseTimer.Start(); - } - - private void TokenUsagePopup_MouseEnter(object sender, MouseEventArgs e) - { - _tokenUsagePopupCloseTimer.Stop(); - } - - private void TokenUsagePopup_MouseLeave(object sender, MouseEventArgs e) - { - _tokenUsagePopupCloseTimer.Stop(); - _tokenUsagePopupCloseTimer.Start(); - } - - private void CloseTokenUsagePopupIfIdle() - { - if (TokenUsagePopup == null) - return; - - var cardHovered = IsMouseInsideElement(TokenUsageCard); - var popupHovered = TokenUsagePopup.Child is FrameworkElement popupChild && IsMouseInsideElement(popupChild); - if (!cardHovered && !popupHovered) - TokenUsagePopup.IsOpen = false; - } - - private static bool IsMouseInsideElement(FrameworkElement? element) - { - if (element == null || !element.IsVisible || element.ActualWidth <= 0 || element.ActualHeight <= 0) - return false; - - try - { - var mouse = System.Windows.Forms.Control.MousePosition; - var point = element.PointFromScreen(new Point(mouse.X, mouse.Y)); - return point.X >= 0 && point.Y >= 0 && point.X <= element.ActualWidth && point.Y <= element.ActualHeight; - } - catch - { - return element.IsMouseOver; - } - } - private static string BuildUsageModelKey(string? service, string? model) { var normalizedService = (service ?? "").Trim().ToLowerInvariant();