AX Agent 권한·컨텍스트 카드 렌더 구조 분리 및 문서 갱신

권한 선택 팝업과 권한 상태 배너 렌더를 ChatWindow.PermissionPresentation partial로 분리했습니다.

컨텍스트 사용량 카드와 hover 팝업 렌더를 ChatWindow.ContextUsagePresentation partial로 분리해 메인 ChatWindow.xaml.cs의 책임을 줄였습니다.

README와 DEVELOPMENT 문서에 2026-04-06 07:31 (KST) 기준 변경 이력을 반영했고 Release 빌드 경고 0 오류 0을 확인했습니다.
This commit is contained in:
2026-04-06 07:33:21 +09:00
parent 90bd77f945
commit 82b42b3ba3
5 changed files with 476 additions and 458 deletions

View File

@@ -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 시 반복 깜빡임을 줄였습니다.

View File

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

View File

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

View File

@@ -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<PermissionModePresentation> 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<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);
}
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<string, bool>(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;
}
}
}
}
}

View File

@@ -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<PermissionModePresentation> 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<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);
}
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<string, bool>(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();