diff --git a/docs/NEXT_ROADMAP.md b/docs/NEXT_ROADMAP.md
index ec8ec57..750edde 100644
--- a/docs/NEXT_ROADMAP.md
+++ b/docs/NEXT_ROADMAP.md
@@ -4659,5 +4659,21 @@ ThemeResourceHelper에 5개 정적 필드 추가:
---
-최종 업데이트: 2026-04-03 (Phase 22~42 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 10차)
+## Phase 43 — 4개 대형 파일 파셜 분할 (v2.3) ✅ 완료
+
+> **목표**: SettingsWindow.AgentConfig·ChatWindow.Presets·PreviewAndFiles·WorkflowAnalyzerWindow 동시 분할.
+
+| 원본 파일 | 원본 | 메인 | 신규 파일 | 신규 줄 수 |
+|----------|------|------|----------|----------|
+| SettingsWindow.AgentConfig.cs | 1,202 | 608 | AgentHooks.cs | 605 |
+| ChatWindow.Presets.cs | 1,280 | 315 | CustomPresets.cs | 978 |
+| ChatWindow.PreviewAndFiles.cs | 1,105 | 709 | FileBrowser.cs | 408 |
+| WorkflowAnalyzerWindow.xaml.cs | 929 | 274 | Charts.cs | 667 |
+
+- **총 신규 파일**: 4개
+- **빌드**: 경고 0, 오류 0
+
+---
+
+최종 업데이트: 2026-04-03 (Phase 22~43 구현 완료 — CC 동등성 37/37 + 코드 품질 리팩터링 11차)
diff --git a/src/AxCopilot/Views/ChatWindow.CustomPresets.cs b/src/AxCopilot/Views/ChatWindow.CustomPresets.cs
new file mode 100644
index 0000000..860ad2e
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.CustomPresets.cs
@@ -0,0 +1,978 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+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 ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null)
+ {
+ bool isEdit = existing != null;
+ var dlg = new CustomPresetDialog(
+ existingName: existing?.Label ?? "",
+ existingDesc: existing?.Description ?? "",
+ existingPrompt: existing?.SystemPrompt ?? "",
+ existingColor: existing?.Color ?? "#6366F1",
+ existingSymbol: existing?.Symbol ?? "\uE713",
+ existingTab: existing?.Tab ?? _activeTab)
+ {
+ Owner = this,
+ };
+
+ if (dlg.ShowDialog() == true)
+ {
+ if (isEdit)
+ {
+ existing!.Label = dlg.PresetName;
+ existing.Description = dlg.PresetDescription;
+ existing.SystemPrompt = dlg.PresetSystemPrompt;
+ existing.Color = dlg.PresetColor;
+ existing.Symbol = dlg.PresetSymbol;
+ existing.Tab = dlg.PresetTab;
+ }
+ else
+ {
+ Llm.CustomPresets.Add(new Models.CustomPresetEntry
+ {
+ Label = dlg.PresetName,
+ Description = dlg.PresetDescription,
+ SystemPrompt = dlg.PresetSystemPrompt,
+ Color = dlg.PresetColor,
+ Symbol = dlg.PresetSymbol,
+ Tab = dlg.PresetTab,
+ });
+ }
+ _settings.Save();
+ BuildTopicButtons();
+ }
+ }
+
+ /// 커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.
+ private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset)
+ {
+ if (anchor == null || preset.CustomId == null) return;
+
+ var popup = new System.Windows.Controls.Primitives.Popup
+ {
+ PlacementTarget = anchor,
+ Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
+ StaysOpen = false,
+ AllowsTransparency = true,
+ };
+
+ var menuBg = ThemeResourceHelper.Background(this);
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var borderBrush = ThemeResourceHelper.Border(this);
+
+ var menuBorder = new Border
+ {
+ Background = menuBg,
+ CornerRadius = new CornerRadius(10),
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(4),
+ MinWidth = 120,
+ Effect = new System.Windows.Media.Effects.DropShadowEffect
+ {
+ BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
+ },
+ };
+
+ var stack = new StackPanel();
+
+ // 편집 버튼
+ var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
+ editItem.MouseLeftButtonDown += (_, _) =>
+ {
+ popup.IsOpen = false;
+ var entry = Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId);
+ if (entry != null) ShowCustomPresetDialog(entry);
+ };
+ stack.Children.Add(editItem);
+
+ // 삭제 버튼
+ var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
+ deleteItem.MouseLeftButtonDown += (_, _) =>
+ {
+ popup.IsOpen = false;
+ var result = CustomMessageBox.Show(
+ $"'{preset.Label}' 프리셋을 삭제하시겠습니까?",
+ "프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
+ if (result == MessageBoxResult.Yes)
+ {
+ Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId);
+ _settings.Save();
+ BuildTopicButtons();
+ }
+ };
+ stack.Children.Add(deleteItem);
+
+ menuBorder.Child = stack;
+ popup.Child = menuBorder;
+ popup.IsOpen = true;
+ }
+
+ /// 컨텍스트 메뉴 항목을 생성합니다.
+ private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg)
+ {
+ var item = new Border
+ {
+ Background = Brushes.Transparent,
+ CornerRadius = new CornerRadius(6),
+ Padding = new Thickness(10, 6, 14, 6),
+ Cursor = Cursors.Hand,
+ };
+
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+ sp.Children.Add(new TextBlock
+ {
+ Text = icon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 13, Foreground = fg,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0),
+ });
+ sp.Children.Add(new TextBlock
+ {
+ Text = label, FontSize = 13, Foreground = fg,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ item.Child = sp;
+
+ var hoverBg = ThemeResourceHelper.HoverBg(this);
+ item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
+ item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
+
+ return item;
+ }
+
+ /// 대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.
+ private void SelectTopic(Services.TopicPreset preset)
+ {
+ bool hasMessages;
+ lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0;
+
+ // 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존)
+ bool hasInput = !string.IsNullOrEmpty(InputBox.Text);
+ bool keepConversation = hasMessages || hasInput;
+
+ if (!keepConversation)
+ {
+ // 메시지도 입력 텍스트도 없으면 새 대화 시작
+ StartNewConversation();
+ }
+
+ // 프리셋 적용 (기존 대화에도 프리셋 변경 가능)
+ lock (_convLock)
+ {
+ if (_currentConversation != null)
+ {
+ _currentConversation.SystemCommand = preset.SystemPrompt;
+ _currentConversation.Category = preset.Category;
+ }
+ }
+
+ if (!keepConversation)
+ EmptyState.Visibility = Visibility.Collapsed;
+
+ InputBox.Focus();
+
+ if (!string.IsNullOrEmpty(preset.Placeholder))
+ {
+ _promptCardPlaceholder = preset.Placeholder;
+ if (!keepConversation) ShowPlaceholder();
+ }
+
+ if (keepConversation)
+ ShowToast($"프리셋 변경: {preset.Label}");
+
+ // Cowork 탭: 하단 바 갱신
+ if (_activeTab == "Cowork")
+ BuildBottomBar();
+ }
+
+
+
+ /// 선택된 디자인 무드 키 (HtmlSkill에서 사용).
+ private string _selectedMood = null!; // Loaded 이벤트에서 초기화
+ private string _selectedLanguage = "auto"; // Code 탭 개발 언어
+ private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
+
+ /// 하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).
+ private void BuildBottomBar()
+ {
+ MoodIconPanel.Children.Clear();
+
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+
+ // ── 포맷 버튼 ──
+ var currentFormat = Llm.DefaultOutputFormat ?? "auto";
+ var formatLabel = GetFormatLabel(currentFormat);
+ var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6");
+ formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); };
+ // Name 등록 (Popup PlacementTarget용)
+ try { RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
+ MoodIconPanel.Children.Add(formatBtn);
+
+ // 구분선
+ MoodIconPanel.Children.Add(new Border
+ {
+ Width = 1, Height = 18,
+ Background = ThemeResourceHelper.Separator(this),
+ Margin = new Thickness(4, 0, 4, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ // ── 디자인 버튼 (소극 스타일) ──
+ var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood);
+ var moodLabel = currentMood?.Label ?? "모던";
+ var moodIcon = currentMood?.Icon ?? "🔷";
+ var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택");
+ moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); };
+ try { RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
+ MoodIconPanel.Children.Add(moodBtn);
+
+ // 구분선
+ MoodIconPanel.Children.Add(new Border
+ {
+ Width = 1, Height = 18,
+ Background = ThemeResourceHelper.Separator(this),
+ Margin = new Thickness(4, 0, 4, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ // ── 파일 탐색기 토글 버튼 ──
+ var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
+ fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
+ MoodIconPanel.Children.Add(fileBrowserBtn);
+
+ // ── 실행 이력 상세도 버튼 ──
+ AppendLogLevelButton();
+
+ // 구분선 표시
+ if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
+ }
+
+ /// Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.
+ private void BuildCodeBottomBar()
+ {
+ MoodIconPanel.Children.Clear();
+
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+
+ // 개발 언어 선택 버튼
+ var langLabel = _selectedLanguage switch
+ {
+ "python" => "🐍 Python",
+ "java" => "☕ Java",
+ "csharp" => "🔷 C#",
+ "cpp" => "⚙ C++",
+ "javascript" => "🌐 JavaScript",
+ _ => "🔧 자동 감지",
+ };
+ var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택");
+ langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); };
+ try { RegisterName("BtnLangMenu", langBtn); } catch (Exception) { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
+ MoodIconPanel.Children.Add(langBtn);
+
+ // 구분선
+ MoodIconPanel.Children.Add(new Border
+ {
+ Width = 1, Height = 18,
+ Background = ThemeResourceHelper.Separator(this),
+ Margin = new Thickness(4, 0, 4, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ // 파일 탐색기 토글
+ var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
+ fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
+ MoodIconPanel.Children.Add(fileBrowserBtn);
+
+ // ── 실행 이력 상세도 버튼 ──
+ AppendLogLevelButton();
+
+ if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
+ }
+
+ /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.
+ private void AppendLogLevelButton()
+ {
+ // 구분선
+ MoodIconPanel.Children.Add(new Border
+ {
+ Width = 1, Height = 18,
+ Background = ThemeResourceHelper.Separator(this),
+ Margin = new Thickness(4, 0, 4, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ var currentLevel = Llm.AgentLogLevel ?? "simple";
+ var levelLabel = currentLevel switch
+ {
+ "debug" => "디버그",
+ "detailed" => "상세",
+ _ => "간략",
+ };
+ var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669");
+ logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); };
+ try { RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
+ MoodIconPanel.Children.Add(logBtn);
+ }
+
+ /// 실행 이력 상세도 팝업 메뉴를 표시합니다.
+ private void ShowLogLevelMenu()
+ {
+ FormatMenuItems.Children.Clear();
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var accentBrush = ThemeResourceHelper.Accent(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+
+ var levels = new (string Key, string Label, string Desc)[]
+ {
+ ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"),
+ ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"),
+ ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"),
+ };
+
+ var current = Llm.AgentLogLevel ?? "simple";
+
+ foreach (var (key, label, desc) in levels)
+ {
+ var isActive = current == key;
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+ sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
+ sp.Children.Add(new TextBlock
+ {
+ Text = label,
+ FontSize = 13,
+ Foreground = isActive ? accentBrush : primaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0),
+ });
+ sp.Children.Add(new TextBlock
+ {
+ Text = desc,
+ FontSize = 10,
+ Foreground = secondaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ var item = new Border
+ {
+ Child = sp,
+ Padding = new Thickness(12, 8, 12, 8),
+ CornerRadius = new CornerRadius(6),
+ Background = Brushes.Transparent,
+ Cursor = Cursors.Hand,
+ };
+ var hoverBg = ThemeResourceHelper.HoverBg(this);
+ item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg;
+ item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent;
+ item.MouseLeftButtonUp += (_, _) =>
+ {
+ Llm.AgentLogLevel = key;
+ _settings.Save();
+ FormatMenuPopup.IsOpen = false;
+ if (_activeTab == "Cowork") BuildBottomBar();
+ else if (_activeTab == "Code") BuildCodeBottomBar();
+ };
+ FormatMenuItems.Children.Add(item);
+ }
+
+ try
+ {
+ var target = FindName("BtnLogLevelMenu") as UIElement;
+ if (target != null) FormatMenuPopup.PlacementTarget = target;
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ FormatMenuPopup.IsOpen = true;
+ }
+
+ private void ShowLanguageMenu()
+ {
+ FormatMenuItems.Children.Clear();
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var accentBrush = ThemeResourceHelper.Accent(this);
+
+ var languages = new (string Key, string Label, string Icon)[]
+ {
+ ("auto", "자동 감지", "🔧"),
+ ("python", "Python", "🐍"),
+ ("java", "Java", "☕"),
+ ("csharp", "C# (.NET)", "🔷"),
+ ("cpp", "C/C++", "⚙"),
+ ("javascript", "JavaScript / Vue", "🌐"),
+ };
+
+ foreach (var (key, label, icon) in languages)
+ {
+ var isActive = _selectedLanguage == key;
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+ sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
+ sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
+ sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal });
+
+ var itemBorder = new Border
+ {
+ Child = sp, Background = Brushes.Transparent,
+ CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand,
+ Padding = new Thickness(8, 7, 12, 7),
+ };
+ ApplyMenuItemHover(itemBorder);
+
+ var capturedKey = key;
+ itemBorder.MouseLeftButtonUp += (_, _) =>
+ {
+ FormatMenuPopup.IsOpen = false;
+ _selectedLanguage = capturedKey;
+ BuildCodeBottomBar();
+ };
+ FormatMenuItems.Children.Add(itemBorder);
+ }
+
+ if (FindName("BtnLangMenu") is UIElement langTarget)
+ FormatMenuPopup.PlacementTarget = langTarget;
+ FormatMenuPopup.IsOpen = true;
+ }
+
+ /// 폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)
+ private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null)
+ {
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText;
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+
+ if (mdlIcon != null)
+ {
+ sp.Children.Add(new TextBlock
+ {
+ Text = mdlIcon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 12,
+ Foreground = iconColor,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 4, 0),
+ });
+ }
+
+ sp.Children.Add(new TextBlock
+ {
+ Text = label,
+ FontSize = 12,
+ Foreground = secondaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ return new Border
+ {
+ Child = sp,
+ Background = Brushes.Transparent,
+ Padding = new Thickness(6, 4, 6, 4),
+ Cursor = Cursors.Hand,
+ ToolTip = tooltip,
+ };
+ }
+
+
+ private static string GetFormatLabel(string key) => key switch
+ {
+ "xlsx" => "Excel",
+ "html" => "HTML 보고서",
+ "docx" => "Word",
+ "md" => "Markdown",
+ "csv" => "CSV",
+ _ => "AI 자동",
+ };
+
+ /// 현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.
+ private (string Name, string Symbol, string Color) GetAgentIdentity()
+ {
+ string? category = null;
+ lock (_convLock)
+ {
+ category = _currentConversation?.Category;
+ }
+
+ return category switch
+ {
+ // Cowork 프리셋 카테고리
+ "보고서" => ("보고서 에이전트", "◆", "#3B82F6"),
+ "데이터" => ("데이터 분석 에이전트", "◆", "#10B981"),
+ "문서" => ("문서 작성 에이전트", "◆", "#6366F1"),
+ "논문" => ("논문 분석 에이전트", "◆", "#6366F1"),
+ "파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"),
+ "자동화" => ("자동화 에이전트", "◆", "#EF4444"),
+ // Code 프리셋 카테고리
+ "코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"),
+ "리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"),
+ "코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"),
+ "보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"),
+ "테스트" => ("테스트 에이전트", "◆", "#F59E0B"),
+ // Chat 카테고리
+ "연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"),
+ "시스템" => ("시스템 에이전트", "◆", "#64748B"),
+ "수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"),
+ "제품분석" => ("제품분석 에이전트", "◆", "#EC4899"),
+ "경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"),
+ "인사" => ("인사 관리 에이전트", "◆", "#14B8A6"),
+ "제조기술" => ("제조기술 에이전트", "◆", "#F97316"),
+ "재무" => ("재무 분석 에이전트", "◆", "#6366F1"),
+ _ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"),
+ _ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"),
+ _ => ("AX 에이전트", "◆", "#4B5EFC"),
+ };
+ }
+
+ /// 포맷 선택 팝업 메뉴를 표시합니다.
+ private void ShowFormatMenu()
+ {
+ FormatMenuItems.Children.Clear();
+
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var accentBrush = ThemeResourceHelper.Accent(this);
+ var currentFormat = Llm.DefaultOutputFormat ?? "auto";
+
+ var formats = new (string Key, string Label, string Icon, string Color)[]
+ {
+ ("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"),
+ ("xlsx", "Excel", "\uE9F9", "#217346"),
+ ("html", "HTML 보고서", "\uE12B", "#E44D26"),
+ ("docx", "Word", "\uE8A5", "#2B579A"),
+ ("md", "Markdown", "\uE943", "#6B7280"),
+ ("csv", "CSV", "\uE9D9", "#10B981"),
+ };
+
+ foreach (var (key, label, icon, color) in formats)
+ {
+ var isActive = key == currentFormat;
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+
+ // 커스텀 체크 아이콘
+ sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
+
+ sp.Children.Add(new TextBlock
+ {
+ Text = icon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 13,
+ Foreground = BrushFromHex(color),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0),
+ });
+
+ sp.Children.Add(new TextBlock
+ {
+ Text = label, FontSize = 13,
+ Foreground = primaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ var itemBorder = new Border
+ {
+ Child = sp,
+ Background = Brushes.Transparent,
+ CornerRadius = new CornerRadius(8),
+ Cursor = Cursors.Hand,
+ Padding = new Thickness(8, 7, 12, 7),
+ };
+ ApplyMenuItemHover(itemBorder);
+
+ var capturedKey = key;
+ itemBorder.MouseLeftButtonUp += (_, _) =>
+ {
+ FormatMenuPopup.IsOpen = false;
+ Llm.DefaultOutputFormat = capturedKey;
+ _settings.Save();
+ BuildBottomBar();
+ };
+
+ FormatMenuItems.Children.Add(itemBorder);
+ }
+
+ // PlacementTarget을 동적 등록된 버튼으로 설정
+ if (FindName("BtnFormatMenu") is UIElement formatTarget)
+ FormatMenuPopup.PlacementTarget = formatTarget;
+ FormatMenuPopup.IsOpen = true;
+ }
+
+ /// 디자인 무드 선택 팝업 메뉴를 표시합니다.
+ private void ShowMoodMenu()
+ {
+ MoodMenuItems.Children.Clear();
+
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var accentBrush = ThemeResourceHelper.Accent(this);
+ var borderBrush = ThemeResourceHelper.Border(this);
+
+ // 2열 갤러리 그리드
+ var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 };
+
+ foreach (var mood in TemplateService.AllMoods)
+ {
+ var isActive = _selectedMood == mood.Key;
+ var isCustom = Llm.CustomMoods.Any(cm => cm.Key == mood.Key);
+ var colors = TemplateService.GetMoodColors(mood.Key);
+
+ // 미니 프리뷰 카드
+ var previewCard = new Border
+ {
+ Width = 160, Height = 80,
+ CornerRadius = new CornerRadius(6),
+ Background = ThemeResourceHelper.HexBrush(colors.Background),
+ BorderBrush = isActive ? accentBrush : ThemeResourceHelper.HexBrush(colors.Border),
+ BorderThickness = new Thickness(isActive ? 2 : 1),
+ Padding = new Thickness(8, 6, 8, 6),
+ Margin = new Thickness(2),
+ };
+
+ var previewContent = new StackPanel();
+ // 헤딩 라인
+ previewContent.Children.Add(new Border
+ {
+ Width = 60, Height = 6, CornerRadius = new CornerRadius(2),
+ Background = ThemeResourceHelper.HexBrush(colors.PrimaryText),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(0, 0, 0, 4),
+ });
+ // 악센트 라인
+ previewContent.Children.Add(new Border
+ {
+ Width = 40, Height = 3, CornerRadius = new CornerRadius(1),
+ Background = ThemeResourceHelper.HexBrush(colors.Accent),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(0, 0, 0, 6),
+ });
+ // 텍스트 라인들
+ for (int i = 0; i < 3; i++)
+ {
+ previewContent.Children.Add(new Border
+ {
+ Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1),
+ Background = new SolidColorBrush(ThemeResourceHelper.HexColor(colors.SecondaryText)) { Opacity = 0.5 },
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(0, 0, 0, 3),
+ });
+ }
+ // 미니 카드 영역
+ var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
+ for (int i = 0; i < 2; i++)
+ {
+ cardRow.Children.Add(new Border
+ {
+ Width = 28, Height = 14, CornerRadius = new CornerRadius(2),
+ Background = ThemeResourceHelper.HexBrush(colors.CardBg),
+ BorderBrush = ThemeResourceHelper.HexBrush(colors.Border),
+ BorderThickness = new Thickness(0.5),
+ Margin = new Thickness(0, 0, 4, 0),
+ });
+ }
+ previewContent.Children.Add(cardRow);
+ previewCard.Child = previewContent;
+
+ // 무드 라벨
+ var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) };
+ var labelRow = new StackPanel { Orientation = Orientation.Horizontal };
+ labelRow.Children.Add(new TextBlock
+ {
+ Text = mood.Icon, FontSize = 12,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 4, 0),
+ });
+ labelRow.Children.Add(new TextBlock
+ {
+ Text = mood.Label, FontSize = 11.5,
+ Foreground = primaryText,
+ FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ if (isActive)
+ {
+ labelRow.Children.Add(new TextBlock
+ {
+ Text = " ✓", FontSize = 11,
+ Foreground = accentBrush,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ }
+ labelPanel.Children.Add(labelRow);
+
+ // 전체 카드 래퍼
+ var cardWrapper = new Border
+ {
+ CornerRadius = new CornerRadius(8),
+ Background = Brushes.Transparent,
+ Cursor = Cursors.Hand,
+ Padding = new Thickness(4),
+ Margin = new Thickness(2),
+ };
+ var wrapperContent = new StackPanel();
+ wrapperContent.Children.Add(previewCard);
+ wrapperContent.Children.Add(labelPanel);
+ cardWrapper.Child = wrapperContent;
+
+ // 호버
+ cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
+ cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
+
+ var capturedMood = mood;
+ cardWrapper.MouseLeftButtonUp += (_, _) =>
+ {
+ MoodMenuPopup.IsOpen = false;
+ _selectedMood = capturedMood.Key;
+ Llm.DefaultMood = capturedMood.Key;
+ _settings.Save();
+ BuildBottomBar();
+ };
+
+ // 커스텀 무드: 우클릭
+ if (isCustom)
+ {
+ cardWrapper.MouseRightButtonUp += (s, e) =>
+ {
+ e.Handled = true;
+ MoodMenuPopup.IsOpen = false;
+ ShowCustomMoodContextMenu(s as Border, capturedMood.Key);
+ };
+ }
+
+ grid.Children.Add(cardWrapper);
+ }
+
+ MoodMenuItems.Children.Add(grid);
+
+ // ── 구분선 + 추가 버튼 ──
+ MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle
+ {
+ Height = 1,
+ Fill = borderBrush,
+ Margin = new Thickness(8, 4, 8, 4),
+ Opacity = 0.4,
+ });
+
+ var addSp = new StackPanel { Orientation = Orientation.Horizontal };
+ addSp.Children.Add(new TextBlock
+ {
+ Text = "\uE710",
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 13,
+ Foreground = secondaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 0, 8, 0),
+ });
+ addSp.Children.Add(new TextBlock
+ {
+ Text = "커스텀 무드 추가",
+ FontSize = 13,
+ Foreground = secondaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ var addBorder = new Border
+ {
+ Child = addSp,
+ Background = Brushes.Transparent,
+ CornerRadius = new CornerRadius(8),
+ Cursor = Cursors.Hand,
+ Padding = new Thickness(8, 6, 12, 6),
+ };
+ ApplyMenuItemHover(addBorder);
+ addBorder.MouseLeftButtonUp += (_, _) =>
+ {
+ MoodMenuPopup.IsOpen = false;
+ ShowCustomMoodDialog();
+ };
+ MoodMenuItems.Children.Add(addBorder);
+
+ if (FindName("BtnMoodMenu") is UIElement moodTarget)
+ MoodMenuPopup.PlacementTarget = moodTarget;
+ MoodMenuPopup.IsOpen = true;
+ }
+
+ /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다.
+ private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null)
+ {
+ bool isEdit = existing != null;
+ var dlg = new CustomMoodDialog(
+ existingKey: existing?.Key ?? "",
+ existingLabel: existing?.Label ?? "",
+ existingIcon: existing?.Icon ?? "🎯",
+ existingDesc: existing?.Description ?? "",
+ existingCss: existing?.Css ?? "")
+ {
+ Owner = this,
+ };
+
+ if (dlg.ShowDialog() == true)
+ {
+ if (isEdit)
+ {
+ existing!.Label = dlg.MoodLabel;
+ existing.Icon = dlg.MoodIcon;
+ existing.Description = dlg.MoodDescription;
+ existing.Css = dlg.MoodCss;
+ }
+ else
+ {
+ Llm.CustomMoods.Add(new Models.CustomMoodEntry
+ {
+ Key = dlg.MoodKey,
+ Label = dlg.MoodLabel,
+ Icon = dlg.MoodIcon,
+ Description = dlg.MoodDescription,
+ Css = dlg.MoodCss,
+ });
+ }
+ _settings.Save();
+ TemplateService.LoadCustomMoods(Llm.CustomMoods);
+ BuildBottomBar();
+ }
+ }
+
+ /// 커스텀 무드 우클릭 컨텍스트 메뉴.
+ private void ShowCustomMoodContextMenu(Border? anchor, string moodKey)
+ {
+ if (anchor == null) return;
+
+ var popup = new System.Windows.Controls.Primitives.Popup
+ {
+ PlacementTarget = anchor,
+ Placement = System.Windows.Controls.Primitives.PlacementMode.Right,
+ StaysOpen = false, AllowsTransparency = true,
+ };
+
+ var menuBg = ThemeResourceHelper.Background(this);
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var borderBrush = ThemeResourceHelper.Border(this);
+
+ var menuBorder = new Border
+ {
+ Background = menuBg,
+ CornerRadius = new CornerRadius(10),
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(4),
+ MinWidth = 120,
+ Effect = new System.Windows.Media.Effects.DropShadowEffect
+ {
+ BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
+ },
+ };
+
+ var stack = new StackPanel();
+
+ var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
+ editItem.MouseLeftButtonDown += (_, _) =>
+ {
+ popup.IsOpen = false;
+ var entry = Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey);
+ if (entry != null) ShowCustomMoodDialog(entry);
+ };
+ stack.Children.Add(editItem);
+
+ var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
+ deleteItem.MouseLeftButtonDown += (_, _) =>
+ {
+ popup.IsOpen = false;
+ var result = CustomMessageBox.Show(
+ $"이 디자인 무드를 삭제하시겠습니까?",
+ "무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
+ if (result == MessageBoxResult.Yes)
+ {
+ Llm.CustomMoods.RemoveAll(c => c.Key == moodKey);
+ if (_selectedMood == moodKey) _selectedMood = "modern";
+ _settings.Save();
+ TemplateService.LoadCustomMoods(Llm.CustomMoods);
+ BuildBottomBar();
+ }
+ };
+ stack.Children.Add(deleteItem);
+
+ menuBorder.Child = stack;
+ popup.Child = menuBorder;
+ popup.IsOpen = true;
+ }
+
+
+ private string? _promptCardPlaceholder;
+
+ private void ShowPlaceholder()
+ {
+ if (string.IsNullOrEmpty(_promptCardPlaceholder)) return;
+ InputWatermark.Text = _promptCardPlaceholder;
+ InputWatermark.Visibility = Visibility.Visible;
+ InputBox.Text = "";
+ InputBox.Focus();
+ }
+
+ private void UpdateWatermarkVisibility()
+ {
+ // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지)
+ if (_activeSlashCmd != null)
+ {
+ InputWatermark.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text))
+ InputWatermark.Visibility = Visibility.Visible;
+ else
+ InputWatermark.Visibility = Visibility.Collapsed;
+ }
+
+ private void ClearPromptCardPlaceholder()
+ {
+ _promptCardPlaceholder = null;
+ InputWatermark.Visibility = Visibility.Collapsed;
+ }
+
+ private void BtnSettings_Click(object sender, RoutedEventArgs e)
+ {
+ // Phase 32: Shift+클릭 → 인라인 설정 패널 토글, 일반 클릭 → SettingsWindow
+ if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift))
+ {
+ ToggleSettingsPanel();
+ return;
+ }
+
+ if (System.Windows.Application.Current is App app)
+ app.OpenSettingsFromChat();
+ }
+
+ /// Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글.
+ private void ToggleSettingsPanel()
+ {
+ if (SettingsPanel.IsOpen)
+ {
+ SettingsPanel.IsOpen = false;
+ }
+ else
+ {
+ var activeTab = "Chat";
+ if (TabCowork?.IsChecked == true) activeTab = "Cowork";
+ else if (TabCode?.IsChecked == true) activeTab = "Code";
+
+ SettingsPanel.LoadFromSettings(_settings, activeTab);
+ SettingsPanel.CloseRequested -= OnSettingsPanelClose;
+ SettingsPanel.CloseRequested += OnSettingsPanelClose;
+ SettingsPanel.IsOpen = true;
+ }
+ }
+
+ private void OnSettingsPanelClose(object? sender, EventArgs e)
+ {
+ SettingsPanel.IsOpen = false;
+ }
+}
diff --git a/src/AxCopilot/Views/ChatWindow.FileBrowser.cs b/src/AxCopilot/Views/ChatWindow.FileBrowser.cs
new file mode 100644
index 0000000..507fee4
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.FileBrowser.cs
@@ -0,0 +1,408 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Threading;
+using AxCopilot.Services.Agent;
+
+namespace AxCopilot.Views;
+
+public partial class ChatWindow
+{
+ // ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
+
+ private DateTime _progressStartTime;
+ private DispatcherTimer? _progressElapsedTimer;
+
+ private void UpdateAgentProgressBar(AgentEvent evt)
+ {
+ switch (evt.Type)
+ {
+ case AgentEventType.Planning when evt.Steps is { Count: > 0 }:
+ ShowStickyProgress(evt.Steps.Count);
+ break;
+
+ case AgentEventType.StepStart when evt.StepTotal > 0:
+ UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary);
+ break;
+
+ case AgentEventType.Complete:
+ HideStickyProgress();
+ break;
+ }
+ }
+
+ private void ShowStickyProgress(int totalSteps)
+ {
+ _progressStartTime = DateTime.Now;
+ AgentProgressBar.Visibility = Visibility.Visible;
+ ProgressIcon.Text = "\uE768"; // play
+ ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})";
+ ProgressPercent.Text = "0%";
+ ProgressElapsed.Text = "0:00";
+ ProgressFill.Width = 0;
+
+ // 경과 시간 타이머
+ _progressElapsedTimer?.Stop();
+ _progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ _progressElapsedTimer.Tick += (_, _) =>
+ {
+ var elapsed = DateTime.Now - _progressStartTime;
+ ProgressElapsed.Text = elapsed.TotalHours >= 1
+ ? elapsed.ToString(@"h\:mm\:ss")
+ : elapsed.ToString(@"m\:ss");
+ };
+ _progressElapsedTimer.Start();
+ }
+
+ private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription)
+ {
+ if (AgentProgressBar.Visibility != Visibility.Visible) return;
+
+ var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0;
+ ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})";
+ ProgressPercent.Text = $"{(int)(pct * 100)}%";
+
+ // 프로그레스 바 너비 애니메이션
+ var parentBorder = ProgressFill.Parent as Border;
+ if (parentBorder != null)
+ {
+ var targetWidth = parentBorder.ActualWidth * pct;
+ var anim = new System.Windows.Media.Animation.DoubleAnimation(
+ ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300))
+ {
+ EasingFunction = new System.Windows.Media.Animation.QuadraticEase(),
+ };
+ ProgressFill.BeginAnimation(WidthProperty, anim);
+ }
+ }
+
+ private void HideStickyProgress()
+ {
+ _progressElapsedTimer?.Stop();
+ _progressElapsedTimer = null;
+
+ if (AgentProgressBar.Visibility != Visibility.Visible) return;
+
+ // 완료 표시 후 페이드아웃
+ ProgressIcon.Text = "\uE930"; // check
+ ProgressStepLabel.Text = "작업 완료";
+ ProgressPercent.Text = "100%";
+
+ // 프로그레스 바 100%
+ var parentBorder = ProgressFill.Parent as Border;
+ if (parentBorder != null)
+ {
+ var anim = new System.Windows.Media.Animation.DoubleAnimation(
+ ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200));
+ ProgressFill.BeginAnimation(WidthProperty, anim);
+ }
+
+ // 3초 후 숨기기
+ var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
+ hideTimer.Tick += (_, _) =>
+ {
+ hideTimer.Stop();
+ var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
+ fadeOut.Completed += (_, _) =>
+ {
+ AgentProgressBar.Visibility = Visibility.Collapsed;
+ AgentProgressBar.Opacity = 1;
+ ProgressFill.BeginAnimation(WidthProperty, null);
+ ProgressFill.Width = 0;
+ };
+ AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut);
+ };
+ hideTimer.Start();
+ }
+
+ // ─── 파일 탐색기 ──────────────────────────────────────────────────────
+
+ private static readonly HashSet _ignoredDirs = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
+ "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
+ ".cache", ".next", ".nuxt", "coverage", ".terraform",
+ };
+
+ private DispatcherTimer? _fileBrowserRefreshTimer;
+
+ private void ToggleFileBrowser()
+ {
+ if (FileBrowserPanel.Visibility == Visibility.Visible)
+ {
+ FileBrowserPanel.Visibility = Visibility.Collapsed;
+ Llm.ShowFileBrowser = false;
+ }
+ else
+ {
+ FileBrowserPanel.Visibility = Visibility.Visible;
+ Llm.ShowFileBrowser = true;
+ BuildFileTree();
+ }
+ _settings.Save();
+ }
+
+ private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
+
+ private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e)
+ {
+ var folder = GetCurrentWorkFolder();
+ if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return;
+ try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch (Exception) { /* 폴더 열기 실패 */ }
+ }
+
+ private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e)
+ {
+ FileBrowserPanel.Visibility = Visibility.Collapsed;
+ }
+
+ private void BuildFileTree()
+ {
+ FileTreeView.Items.Clear();
+ var folder = GetCurrentWorkFolder();
+ if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder))
+ {
+ FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
+ return;
+ }
+
+ FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}";
+ var count = 0;
+ PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
+ }
+
+ private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count)
+ {
+ if (depth > 4 || count > 200) return;
+
+ // 디렉터리
+ try
+ {
+ foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
+ {
+ if (count > 200) break;
+ if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue;
+
+ count++;
+ var dirItem = new TreeViewItem
+ {
+ Header = CreateFileTreeHeader("\uED25", subDir.Name, null),
+ Tag = subDir.FullName,
+ IsExpanded = depth < 1,
+ };
+
+ // 지연 로딩: 더미 자식 → 펼칠 때 실제 로드
+ if (depth < 3)
+ {
+ dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미
+ var capturedDir = subDir;
+ var capturedDepth = depth;
+ dirItem.Expanded += (s, _) =>
+ {
+ if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
+ {
+ ti.Items.Clear();
+ int c = 0;
+ PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
+ }
+ };
+ }
+ else
+ {
+ PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count);
+ }
+
+ items.Add(dirItem);
+ }
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+
+ // 파일
+ try
+ {
+ foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
+ {
+ if (count > 200) break;
+ count++;
+
+ var ext = file.Extension.ToLowerInvariant();
+ var icon = GetFileIcon(ext);
+ var size = FormatFileSize(file.Length);
+
+ var fileItem = new TreeViewItem
+ {
+ Header = CreateFileTreeHeader(icon, file.Name, size),
+ Tag = file.FullName,
+ };
+
+ // 더블클릭 → 프리뷰
+ var capturedPath = file.FullName;
+ fileItem.MouseDoubleClick += (s, e) =>
+ {
+ e.Handled = true;
+ TryShowPreview(capturedPath);
+ };
+
+ // 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음)
+ fileItem.MouseRightButtonUp += (s, e) =>
+ {
+ e.Handled = true;
+ if (s is TreeViewItem ti) ti.IsSelected = true;
+ ShowFileTreeContextMenu(capturedPath);
+ };
+
+ items.Add(fileItem);
+ }
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ }
+
+ private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText)
+ {
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+ sp.Children.Add(new TextBlock
+ {
+ Text = icon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 11,
+ Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 5, 0),
+ });
+ sp.Children.Add(new TextBlock
+ {
+ Text = name,
+ FontSize = 11.5,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ if (sizeText != null)
+ {
+ sp.Children.Add(new TextBlock
+ {
+ Text = $" {sizeText}",
+ FontSize = 10,
+ Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ }
+ return sp;
+ }
+
+ private static string GetFileIcon(string ext) => ext switch
+ {
+ ".html" or ".htm" => "\uEB41",
+ ".xlsx" or ".xls" => "\uE9F9",
+ ".docx" or ".doc" => "\uE8A5",
+ ".pdf" => "\uEA90",
+ ".csv" => "\uE80A",
+ ".md" => "\uE70B",
+ ".json" or ".xml" => "\uE943",
+ ".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F",
+ ".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943",
+ ".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756",
+ ".txt" or ".log" => "\uE8A5",
+ _ => "\uE7C3",
+ };
+
+ private static string FormatFileSize(long bytes) => bytes switch
+ {
+ < 1024 => $"{bytes} B",
+ < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
+ _ => $"{bytes / (1024.0 * 1024.0):F1} MB",
+ };
+
+ private void ShowFileTreeContextMenu(string filePath)
+ {
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var hoverBg = ThemeResourceHelper.Hint(this);
+ var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C));
+
+ var (popup, panel) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200);
+
+ void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null)
+ => panel.Children.Add(PopupMenuHelper.MenuItem(label, labelColor ?? primaryText, hoverBg,
+ () => { popup.IsOpen = false; action(); },
+ icon: icon, iconColor: iconColor ?? secondaryText, fontSize: 12.5));
+
+ void AddSep() => panel.Children.Add(PopupMenuHelper.Separator());
+
+ var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
+ if (_previewableExtensions.Contains(ext))
+ AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
+
+ AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
+ {
+ try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch (Exception) { /* 파일 열기 실패 */ }
+ });
+ AddItem("\uED25", "폴더에서 보기", () =>
+ {
+ try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch (Exception) { /* 탐색기 열기 실패 */ }
+ });
+ AddItem("\uE8C8", "경로 복사", () =>
+ {
+ try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ }
+ });
+
+ AddSep();
+
+ // 이름 변경
+ AddItem("\uE8AC", "이름 변경", () =>
+ {
+ var dir = System.IO.Path.GetDirectoryName(filePath) ?? "";
+ var oldName = System.IO.Path.GetFileName(filePath);
+ var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
+ if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
+ {
+ var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim());
+ try
+ {
+ System.IO.File.Move(filePath, newPath);
+ BuildFileTree();
+ ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
+ }
+ catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); }
+ }
+ });
+
+ // 삭제
+ AddItem("\uE74D", "삭제", () =>
+ {
+ var result = MessageBox.Show(
+ $"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}",
+ "파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
+ if (result == MessageBoxResult.Yes)
+ {
+ try
+ {
+ System.IO.File.Delete(filePath);
+ BuildFileTree();
+ ShowToast("파일 삭제됨");
+ }
+ catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); }
+ }
+ }, dangerBrush, dangerBrush);
+
+ // Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음
+ Dispatcher.BeginInvoke(() => { popup.IsOpen = true; },
+ System.Windows.Threading.DispatcherPriority.Input);
+ }
+
+ /// 에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.
+ private void RefreshFileTreeIfVisible()
+ {
+ if (FileBrowserPanel.Visibility != Visibility.Visible) return;
+
+ // 디바운스: 500ms 내 중복 호출 방지
+ _fileBrowserRefreshTimer?.Stop();
+ _fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
+ _fileBrowserRefreshTimer.Tick += (_, _) =>
+ {
+ _fileBrowserRefreshTimer.Stop();
+ BuildFileTree();
+ };
+ _fileBrowserRefreshTimer.Start();
+ }
+}
diff --git a/src/AxCopilot/Views/ChatWindow.Presets.cs b/src/AxCopilot/Views/ChatWindow.Presets.cs
index bf0e68f..41adeab 100644
--- a/src/AxCopilot/Views/ChatWindow.Presets.cs
+++ b/src/AxCopilot/Views/ChatWindow.Presets.cs
@@ -312,969 +312,4 @@ public partial class ChatWindow
TopicButtonPanel.Children.Add(addBorder);
}
}
-
- // ─── 커스텀 프리셋 관리 ─────────────────────────────────────────────
-
- /// 커스텀 프리셋 추가 다이얼로그를 표시합니다.
- private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null)
- {
- bool isEdit = existing != null;
- var dlg = new CustomPresetDialog(
- existingName: existing?.Label ?? "",
- existingDesc: existing?.Description ?? "",
- existingPrompt: existing?.SystemPrompt ?? "",
- existingColor: existing?.Color ?? "#6366F1",
- existingSymbol: existing?.Symbol ?? "\uE713",
- existingTab: existing?.Tab ?? _activeTab)
- {
- Owner = this,
- };
-
- if (dlg.ShowDialog() == true)
- {
- if (isEdit)
- {
- existing!.Label = dlg.PresetName;
- existing.Description = dlg.PresetDescription;
- existing.SystemPrompt = dlg.PresetSystemPrompt;
- existing.Color = dlg.PresetColor;
- existing.Symbol = dlg.PresetSymbol;
- existing.Tab = dlg.PresetTab;
- }
- else
- {
- Llm.CustomPresets.Add(new Models.CustomPresetEntry
- {
- Label = dlg.PresetName,
- Description = dlg.PresetDescription,
- SystemPrompt = dlg.PresetSystemPrompt,
- Color = dlg.PresetColor,
- Symbol = dlg.PresetSymbol,
- Tab = dlg.PresetTab,
- });
- }
- _settings.Save();
- BuildTopicButtons();
- }
- }
-
- /// 커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.
- private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset)
- {
- if (anchor == null || preset.CustomId == null) return;
-
- var popup = new System.Windows.Controls.Primitives.Popup
- {
- PlacementTarget = anchor,
- Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
- StaysOpen = false,
- AllowsTransparency = true,
- };
-
- var menuBg = ThemeResourceHelper.Background(this);
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var borderBrush = ThemeResourceHelper.Border(this);
-
- var menuBorder = new Border
- {
- Background = menuBg,
- CornerRadius = new CornerRadius(10),
- BorderBrush = borderBrush,
- BorderThickness = new Thickness(1),
- Padding = new Thickness(4),
- MinWidth = 120,
- Effect = new System.Windows.Media.Effects.DropShadowEffect
- {
- BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
- },
- };
-
- var stack = new StackPanel();
-
- // 편집 버튼
- var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
- editItem.MouseLeftButtonDown += (_, _) =>
- {
- popup.IsOpen = false;
- var entry = Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId);
- if (entry != null) ShowCustomPresetDialog(entry);
- };
- stack.Children.Add(editItem);
-
- // 삭제 버튼
- var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
- deleteItem.MouseLeftButtonDown += (_, _) =>
- {
- popup.IsOpen = false;
- var result = CustomMessageBox.Show(
- $"'{preset.Label}' 프리셋을 삭제하시겠습니까?",
- "프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
- if (result == MessageBoxResult.Yes)
- {
- Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId);
- _settings.Save();
- BuildTopicButtons();
- }
- };
- stack.Children.Add(deleteItem);
-
- menuBorder.Child = stack;
- popup.Child = menuBorder;
- popup.IsOpen = true;
- }
-
- /// 컨텍스트 메뉴 항목을 생성합니다.
- private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg)
- {
- var item = new Border
- {
- Background = Brushes.Transparent,
- CornerRadius = new CornerRadius(6),
- Padding = new Thickness(10, 6, 14, 6),
- Cursor = Cursors.Hand,
- };
-
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
- sp.Children.Add(new TextBlock
- {
- Text = icon,
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 13, Foreground = fg,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 8, 0),
- });
- sp.Children.Add(new TextBlock
- {
- Text = label, FontSize = 13, Foreground = fg,
- VerticalAlignment = VerticalAlignment.Center,
- });
- item.Child = sp;
-
- var hoverBg = ThemeResourceHelper.HoverBg(this);
- item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
- item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
-
- return item;
- }
-
- /// 대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.
- private void SelectTopic(Services.TopicPreset preset)
- {
- bool hasMessages;
- lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0;
-
- // 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존)
- bool hasInput = !string.IsNullOrEmpty(InputBox.Text);
- bool keepConversation = hasMessages || hasInput;
-
- if (!keepConversation)
- {
- // 메시지도 입력 텍스트도 없으면 새 대화 시작
- StartNewConversation();
- }
-
- // 프리셋 적용 (기존 대화에도 프리셋 변경 가능)
- lock (_convLock)
- {
- if (_currentConversation != null)
- {
- _currentConversation.SystemCommand = preset.SystemPrompt;
- _currentConversation.Category = preset.Category;
- }
- }
-
- if (!keepConversation)
- EmptyState.Visibility = Visibility.Collapsed;
-
- InputBox.Focus();
-
- if (!string.IsNullOrEmpty(preset.Placeholder))
- {
- _promptCardPlaceholder = preset.Placeholder;
- if (!keepConversation) ShowPlaceholder();
- }
-
- if (keepConversation)
- ShowToast($"프리셋 변경: {preset.Label}");
-
- // Cowork 탭: 하단 바 갱신
- if (_activeTab == "Cowork")
- BuildBottomBar();
- }
-
-
-
- /// 선택된 디자인 무드 키 (HtmlSkill에서 사용).
- private string _selectedMood = null!; // Loaded 이벤트에서 초기화
- private string _selectedLanguage = "auto"; // Code 탭 개발 언어
- private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
-
- /// 하단 바를 구성합니다 (포맷 + 디자인 드롭다운 버튼).
- private void BuildBottomBar()
- {
- MoodIconPanel.Children.Clear();
-
- var secondaryText = ThemeResourceHelper.Secondary(this);
-
- // ── 포맷 버튼 ──
- var currentFormat = Llm.DefaultOutputFormat ?? "auto";
- var formatLabel = GetFormatLabel(currentFormat);
- var formatBtn = CreateFolderBarButton("\uE9F9", formatLabel, "보고서 형태 선택", "#8B5CF6");
- formatBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFormatMenu(); };
- // Name 등록 (Popup PlacementTarget용)
- try { RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { try { UnregisterName("BtnFormatMenu"); RegisterName("BtnFormatMenu", formatBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
- MoodIconPanel.Children.Add(formatBtn);
-
- // 구분선
- MoodIconPanel.Children.Add(new Border
- {
- Width = 1, Height = 18,
- Background = ThemeResourceHelper.Separator(this),
- Margin = new Thickness(4, 0, 4, 0),
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- // ── 디자인 버튼 (소극 스타일) ──
- var currentMood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == _selectedMood);
- var moodLabel = currentMood?.Label ?? "모던";
- var moodIcon = currentMood?.Icon ?? "🔷";
- var moodBtn = CreateFolderBarButton(null, $"{moodIcon} {moodLabel}", "디자인 무드 선택");
- moodBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowMoodMenu(); };
- try { RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { try { UnregisterName("BtnMoodMenu"); RegisterName("BtnMoodMenu", moodBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
- MoodIconPanel.Children.Add(moodBtn);
-
- // 구분선
- MoodIconPanel.Children.Add(new Border
- {
- Width = 1, Height = 18,
- Background = ThemeResourceHelper.Separator(this),
- Margin = new Thickness(4, 0, 4, 0),
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- // ── 파일 탐색기 토글 버튼 ──
- var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
- fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
- MoodIconPanel.Children.Add(fileBrowserBtn);
-
- // ── 실행 이력 상세도 버튼 ──
- AppendLogLevelButton();
-
- // 구분선 표시
- if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
- }
-
- /// Code 탭 하단 바: 개발 언어 선택 + 파일 탐색기 토글.
- private void BuildCodeBottomBar()
- {
- MoodIconPanel.Children.Clear();
-
- var secondaryText = ThemeResourceHelper.Secondary(this);
-
- // 개발 언어 선택 버튼
- var langLabel = _selectedLanguage switch
- {
- "python" => "🐍 Python",
- "java" => "☕ Java",
- "csharp" => "🔷 C#",
- "cpp" => "⚙ C++",
- "javascript" => "🌐 JavaScript",
- _ => "🔧 자동 감지",
- };
- var langBtn = CreateFolderBarButton(null, langLabel, "개발 언어 선택");
- langBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLanguageMenu(); };
- try { RegisterName("BtnLangMenu", langBtn); } catch (Exception) { try { UnregisterName("BtnLangMenu"); RegisterName("BtnLangMenu", langBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
- MoodIconPanel.Children.Add(langBtn);
-
- // 구분선
- MoodIconPanel.Children.Add(new Border
- {
- Width = 1, Height = 18,
- Background = ThemeResourceHelper.Separator(this),
- Margin = new Thickness(4, 0, 4, 0),
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- // 파일 탐색기 토글
- var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
- fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
- MoodIconPanel.Children.Add(fileBrowserBtn);
-
- // ── 실행 이력 상세도 버튼 ──
- AppendLogLevelButton();
-
- if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
- }
-
- /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.
- private void AppendLogLevelButton()
- {
- // 구분선
- MoodIconPanel.Children.Add(new Border
- {
- Width = 1, Height = 18,
- Background = ThemeResourceHelper.Separator(this),
- Margin = new Thickness(4, 0, 4, 0),
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- var currentLevel = Llm.AgentLogLevel ?? "simple";
- var levelLabel = currentLevel switch
- {
- "debug" => "디버그",
- "detailed" => "상세",
- _ => "간략",
- };
- var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669");
- logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); };
- try { RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch (Exception) { /* 이름 등록 실패 */ } }
- MoodIconPanel.Children.Add(logBtn);
- }
-
- /// 실행 이력 상세도 팝업 메뉴를 표시합니다.
- private void ShowLogLevelMenu()
- {
- FormatMenuItems.Children.Clear();
- var primaryText = ThemeResourceHelper.Primary(this);
- var accentBrush = ThemeResourceHelper.Accent(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
-
- var levels = new (string Key, string Label, string Desc)[]
- {
- ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"),
- ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"),
- ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"),
- };
-
- var current = Llm.AgentLogLevel ?? "simple";
-
- foreach (var (key, label, desc) in levels)
- {
- var isActive = current == key;
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
- sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
- sp.Children.Add(new TextBlock
- {
- Text = label,
- FontSize = 13,
- Foreground = isActive ? accentBrush : primaryText,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 8, 0),
- });
- sp.Children.Add(new TextBlock
- {
- Text = desc,
- FontSize = 10,
- Foreground = secondaryText,
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- var item = new Border
- {
- Child = sp,
- Padding = new Thickness(12, 8, 12, 8),
- CornerRadius = new CornerRadius(6),
- Background = Brushes.Transparent,
- Cursor = Cursors.Hand,
- };
- var hoverBg = ThemeResourceHelper.HoverBg(this);
- item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg;
- item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent;
- item.MouseLeftButtonUp += (_, _) =>
- {
- Llm.AgentLogLevel = key;
- _settings.Save();
- FormatMenuPopup.IsOpen = false;
- if (_activeTab == "Cowork") BuildBottomBar();
- else if (_activeTab == "Code") BuildCodeBottomBar();
- };
- FormatMenuItems.Children.Add(item);
- }
-
- try
- {
- var target = FindName("BtnLogLevelMenu") as UIElement;
- if (target != null) FormatMenuPopup.PlacementTarget = target;
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- FormatMenuPopup.IsOpen = true;
- }
-
- private void ShowLanguageMenu()
- {
- FormatMenuItems.Children.Clear();
- var primaryText = ThemeResourceHelper.Primary(this);
- var accentBrush = ThemeResourceHelper.Accent(this);
-
- var languages = new (string Key, string Label, string Icon)[]
- {
- ("auto", "자동 감지", "🔧"),
- ("python", "Python", "🐍"),
- ("java", "Java", "☕"),
- ("csharp", "C# (.NET)", "🔷"),
- ("cpp", "C/C++", "⚙"),
- ("javascript", "JavaScript / Vue", "🌐"),
- };
-
- foreach (var (key, label, icon) in languages)
- {
- var isActive = _selectedLanguage == key;
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
- sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
- sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
- sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal });
-
- var itemBorder = new Border
- {
- Child = sp, Background = Brushes.Transparent,
- CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand,
- Padding = new Thickness(8, 7, 12, 7),
- };
- ApplyMenuItemHover(itemBorder);
-
- var capturedKey = key;
- itemBorder.MouseLeftButtonUp += (_, _) =>
- {
- FormatMenuPopup.IsOpen = false;
- _selectedLanguage = capturedKey;
- BuildCodeBottomBar();
- };
- FormatMenuItems.Children.Add(itemBorder);
- }
-
- if (FindName("BtnLangMenu") is UIElement langTarget)
- FormatMenuPopup.PlacementTarget = langTarget;
- FormatMenuPopup.IsOpen = true;
- }
-
- /// 폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)
- private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null)
- {
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText;
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
-
- if (mdlIcon != null)
- {
- sp.Children.Add(new TextBlock
- {
- Text = mdlIcon,
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 12,
- Foreground = iconColor,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 4, 0),
- });
- }
-
- sp.Children.Add(new TextBlock
- {
- Text = label,
- FontSize = 12,
- Foreground = secondaryText,
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- return new Border
- {
- Child = sp,
- Background = Brushes.Transparent,
- Padding = new Thickness(6, 4, 6, 4),
- Cursor = Cursors.Hand,
- ToolTip = tooltip,
- };
- }
-
-
- private static string GetFormatLabel(string key) => key switch
- {
- "xlsx" => "Excel",
- "html" => "HTML 보고서",
- "docx" => "Word",
- "md" => "Markdown",
- "csv" => "CSV",
- _ => "AI 자동",
- };
-
- /// 현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.
- private (string Name, string Symbol, string Color) GetAgentIdentity()
- {
- string? category = null;
- lock (_convLock)
- {
- category = _currentConversation?.Category;
- }
-
- return category switch
- {
- // Cowork 프리셋 카테고리
- "보고서" => ("보고서 에이전트", "◆", "#3B82F6"),
- "데이터" => ("데이터 분석 에이전트", "◆", "#10B981"),
- "문서" => ("문서 작성 에이전트", "◆", "#6366F1"),
- "논문" => ("논문 분석 에이전트", "◆", "#6366F1"),
- "파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"),
- "자동화" => ("자동화 에이전트", "◆", "#EF4444"),
- // Code 프리셋 카테고리
- "코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"),
- "리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"),
- "코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"),
- "보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"),
- "테스트" => ("테스트 에이전트", "◆", "#F59E0B"),
- // Chat 카테고리
- "연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"),
- "시스템" => ("시스템 에이전트", "◆", "#64748B"),
- "수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"),
- "제품분석" => ("제품분석 에이전트", "◆", "#EC4899"),
- "경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"),
- "인사" => ("인사 관리 에이전트", "◆", "#14B8A6"),
- "제조기술" => ("제조기술 에이전트", "◆", "#F97316"),
- "재무" => ("재무 분석 에이전트", "◆", "#6366F1"),
- _ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"),
- _ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"),
- _ => ("AX 에이전트", "◆", "#4B5EFC"),
- };
- }
-
- /// 포맷 선택 팝업 메뉴를 표시합니다.
- private void ShowFormatMenu()
- {
- FormatMenuItems.Children.Clear();
-
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var accentBrush = ThemeResourceHelper.Accent(this);
- var currentFormat = Llm.DefaultOutputFormat ?? "auto";
-
- var formats = new (string Key, string Label, string Icon, string Color)[]
- {
- ("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"),
- ("xlsx", "Excel", "\uE9F9", "#217346"),
- ("html", "HTML 보고서", "\uE12B", "#E44D26"),
- ("docx", "Word", "\uE8A5", "#2B579A"),
- ("md", "Markdown", "\uE943", "#6B7280"),
- ("csv", "CSV", "\uE9D9", "#10B981"),
- };
-
- foreach (var (key, label, icon, color) in formats)
- {
- var isActive = key == currentFormat;
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
-
- // 커스텀 체크 아이콘
- sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
-
- sp.Children.Add(new TextBlock
- {
- Text = icon,
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 13,
- Foreground = BrushFromHex(color),
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 8, 0),
- });
-
- sp.Children.Add(new TextBlock
- {
- Text = label, FontSize = 13,
- Foreground = primaryText,
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- var itemBorder = new Border
- {
- Child = sp,
- Background = Brushes.Transparent,
- CornerRadius = new CornerRadius(8),
- Cursor = Cursors.Hand,
- Padding = new Thickness(8, 7, 12, 7),
- };
- ApplyMenuItemHover(itemBorder);
-
- var capturedKey = key;
- itemBorder.MouseLeftButtonUp += (_, _) =>
- {
- FormatMenuPopup.IsOpen = false;
- Llm.DefaultOutputFormat = capturedKey;
- _settings.Save();
- BuildBottomBar();
- };
-
- FormatMenuItems.Children.Add(itemBorder);
- }
-
- // PlacementTarget을 동적 등록된 버튼으로 설정
- if (FindName("BtnFormatMenu") is UIElement formatTarget)
- FormatMenuPopup.PlacementTarget = formatTarget;
- FormatMenuPopup.IsOpen = true;
- }
-
- /// 디자인 무드 선택 팝업 메뉴를 표시합니다.
- private void ShowMoodMenu()
- {
- MoodMenuItems.Children.Clear();
-
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var accentBrush = ThemeResourceHelper.Accent(this);
- var borderBrush = ThemeResourceHelper.Border(this);
-
- // 2열 갤러리 그리드
- var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 };
-
- foreach (var mood in TemplateService.AllMoods)
- {
- var isActive = _selectedMood == mood.Key;
- var isCustom = Llm.CustomMoods.Any(cm => cm.Key == mood.Key);
- var colors = TemplateService.GetMoodColors(mood.Key);
-
- // 미니 프리뷰 카드
- var previewCard = new Border
- {
- Width = 160, Height = 80,
- CornerRadius = new CornerRadius(6),
- Background = ThemeResourceHelper.HexBrush(colors.Background),
- BorderBrush = isActive ? accentBrush : ThemeResourceHelper.HexBrush(colors.Border),
- BorderThickness = new Thickness(isActive ? 2 : 1),
- Padding = new Thickness(8, 6, 8, 6),
- Margin = new Thickness(2),
- };
-
- var previewContent = new StackPanel();
- // 헤딩 라인
- previewContent.Children.Add(new Border
- {
- Width = 60, Height = 6, CornerRadius = new CornerRadius(2),
- Background = ThemeResourceHelper.HexBrush(colors.PrimaryText),
- HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 0, 0, 4),
- });
- // 악센트 라인
- previewContent.Children.Add(new Border
- {
- Width = 40, Height = 3, CornerRadius = new CornerRadius(1),
- Background = ThemeResourceHelper.HexBrush(colors.Accent),
- HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 0, 0, 6),
- });
- // 텍스트 라인들
- for (int i = 0; i < 3; i++)
- {
- previewContent.Children.Add(new Border
- {
- Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1),
- Background = new SolidColorBrush(ThemeResourceHelper.HexColor(colors.SecondaryText)) { Opacity = 0.5 },
- HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 0, 0, 3),
- });
- }
- // 미니 카드 영역
- var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
- for (int i = 0; i < 2; i++)
- {
- cardRow.Children.Add(new Border
- {
- Width = 28, Height = 14, CornerRadius = new CornerRadius(2),
- Background = ThemeResourceHelper.HexBrush(colors.CardBg),
- BorderBrush = ThemeResourceHelper.HexBrush(colors.Border),
- BorderThickness = new Thickness(0.5),
- Margin = new Thickness(0, 0, 4, 0),
- });
- }
- previewContent.Children.Add(cardRow);
- previewCard.Child = previewContent;
-
- // 무드 라벨
- var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) };
- var labelRow = new StackPanel { Orientation = Orientation.Horizontal };
- labelRow.Children.Add(new TextBlock
- {
- Text = mood.Icon, FontSize = 12,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 4, 0),
- });
- labelRow.Children.Add(new TextBlock
- {
- Text = mood.Label, FontSize = 11.5,
- Foreground = primaryText,
- FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
- VerticalAlignment = VerticalAlignment.Center,
- });
- if (isActive)
- {
- labelRow.Children.Add(new TextBlock
- {
- Text = " ✓", FontSize = 11,
- Foreground = accentBrush,
- VerticalAlignment = VerticalAlignment.Center,
- });
- }
- labelPanel.Children.Add(labelRow);
-
- // 전체 카드 래퍼
- var cardWrapper = new Border
- {
- CornerRadius = new CornerRadius(8),
- Background = Brushes.Transparent,
- Cursor = Cursors.Hand,
- Padding = new Thickness(4),
- Margin = new Thickness(2),
- };
- var wrapperContent = new StackPanel();
- wrapperContent.Children.Add(previewCard);
- wrapperContent.Children.Add(labelPanel);
- cardWrapper.Child = wrapperContent;
-
- // 호버
- cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
- cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
-
- var capturedMood = mood;
- cardWrapper.MouseLeftButtonUp += (_, _) =>
- {
- MoodMenuPopup.IsOpen = false;
- _selectedMood = capturedMood.Key;
- Llm.DefaultMood = capturedMood.Key;
- _settings.Save();
- BuildBottomBar();
- };
-
- // 커스텀 무드: 우클릭
- if (isCustom)
- {
- cardWrapper.MouseRightButtonUp += (s, e) =>
- {
- e.Handled = true;
- MoodMenuPopup.IsOpen = false;
- ShowCustomMoodContextMenu(s as Border, capturedMood.Key);
- };
- }
-
- grid.Children.Add(cardWrapper);
- }
-
- MoodMenuItems.Children.Add(grid);
-
- // ── 구분선 + 추가 버튼 ──
- MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle
- {
- Height = 1,
- Fill = borderBrush,
- Margin = new Thickness(8, 4, 8, 4),
- Opacity = 0.4,
- });
-
- var addSp = new StackPanel { Orientation = Orientation.Horizontal };
- addSp.Children.Add(new TextBlock
- {
- Text = "\uE710",
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 13,
- Foreground = secondaryText,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(4, 0, 8, 0),
- });
- addSp.Children.Add(new TextBlock
- {
- Text = "커스텀 무드 추가",
- FontSize = 13,
- Foreground = secondaryText,
- VerticalAlignment = VerticalAlignment.Center,
- });
- var addBorder = new Border
- {
- Child = addSp,
- Background = Brushes.Transparent,
- CornerRadius = new CornerRadius(8),
- Cursor = Cursors.Hand,
- Padding = new Thickness(8, 6, 12, 6),
- };
- ApplyMenuItemHover(addBorder);
- addBorder.MouseLeftButtonUp += (_, _) =>
- {
- MoodMenuPopup.IsOpen = false;
- ShowCustomMoodDialog();
- };
- MoodMenuItems.Children.Add(addBorder);
-
- if (FindName("BtnMoodMenu") is UIElement moodTarget)
- MoodMenuPopup.PlacementTarget = moodTarget;
- MoodMenuPopup.IsOpen = true;
- }
-
- /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다.
- private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null)
- {
- bool isEdit = existing != null;
- var dlg = new CustomMoodDialog(
- existingKey: existing?.Key ?? "",
- existingLabel: existing?.Label ?? "",
- existingIcon: existing?.Icon ?? "🎯",
- existingDesc: existing?.Description ?? "",
- existingCss: existing?.Css ?? "")
- {
- Owner = this,
- };
-
- if (dlg.ShowDialog() == true)
- {
- if (isEdit)
- {
- existing!.Label = dlg.MoodLabel;
- existing.Icon = dlg.MoodIcon;
- existing.Description = dlg.MoodDescription;
- existing.Css = dlg.MoodCss;
- }
- else
- {
- Llm.CustomMoods.Add(new Models.CustomMoodEntry
- {
- Key = dlg.MoodKey,
- Label = dlg.MoodLabel,
- Icon = dlg.MoodIcon,
- Description = dlg.MoodDescription,
- Css = dlg.MoodCss,
- });
- }
- _settings.Save();
- TemplateService.LoadCustomMoods(Llm.CustomMoods);
- BuildBottomBar();
- }
- }
-
- /// 커스텀 무드 우클릭 컨텍스트 메뉴.
- private void ShowCustomMoodContextMenu(Border? anchor, string moodKey)
- {
- if (anchor == null) return;
-
- var popup = new System.Windows.Controls.Primitives.Popup
- {
- PlacementTarget = anchor,
- Placement = System.Windows.Controls.Primitives.PlacementMode.Right,
- StaysOpen = false, AllowsTransparency = true,
- };
-
- var menuBg = ThemeResourceHelper.Background(this);
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var borderBrush = ThemeResourceHelper.Border(this);
-
- var menuBorder = new Border
- {
- Background = menuBg,
- CornerRadius = new CornerRadius(10),
- BorderBrush = borderBrush,
- BorderThickness = new Thickness(1),
- Padding = new Thickness(4),
- MinWidth = 120,
- Effect = new System.Windows.Media.Effects.DropShadowEffect
- {
- BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
- },
- };
-
- var stack = new StackPanel();
-
- var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
- editItem.MouseLeftButtonDown += (_, _) =>
- {
- popup.IsOpen = false;
- var entry = Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey);
- if (entry != null) ShowCustomMoodDialog(entry);
- };
- stack.Children.Add(editItem);
-
- var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
- deleteItem.MouseLeftButtonDown += (_, _) =>
- {
- popup.IsOpen = false;
- var result = CustomMessageBox.Show(
- $"이 디자인 무드를 삭제하시겠습니까?",
- "무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
- if (result == MessageBoxResult.Yes)
- {
- Llm.CustomMoods.RemoveAll(c => c.Key == moodKey);
- if (_selectedMood == moodKey) _selectedMood = "modern";
- _settings.Save();
- TemplateService.LoadCustomMoods(Llm.CustomMoods);
- BuildBottomBar();
- }
- };
- stack.Children.Add(deleteItem);
-
- menuBorder.Child = stack;
- popup.Child = menuBorder;
- popup.IsOpen = true;
- }
-
-
- private string? _promptCardPlaceholder;
-
- private void ShowPlaceholder()
- {
- if (string.IsNullOrEmpty(_promptCardPlaceholder)) return;
- InputWatermark.Text = _promptCardPlaceholder;
- InputWatermark.Visibility = Visibility.Visible;
- InputBox.Text = "";
- InputBox.Focus();
- }
-
- private void UpdateWatermarkVisibility()
- {
- // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지)
- if (_activeSlashCmd != null)
- {
- InputWatermark.Visibility = Visibility.Collapsed;
- return;
- }
-
- if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text))
- InputWatermark.Visibility = Visibility.Visible;
- else
- InputWatermark.Visibility = Visibility.Collapsed;
- }
-
- private void ClearPromptCardPlaceholder()
- {
- _promptCardPlaceholder = null;
- InputWatermark.Visibility = Visibility.Collapsed;
- }
-
- private void BtnSettings_Click(object sender, RoutedEventArgs e)
- {
- // Phase 32: Shift+클릭 → 인라인 설정 패널 토글, 일반 클릭 → SettingsWindow
- if (System.Windows.Input.Keyboard.Modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift))
- {
- ToggleSettingsPanel();
- return;
- }
-
- if (System.Windows.Application.Current is App app)
- app.OpenSettingsFromChat();
- }
-
- /// Phase 32-E: 우측 설정 패널 슬라이드인/아웃 토글.
- private void ToggleSettingsPanel()
- {
- if (SettingsPanel.IsOpen)
- {
- SettingsPanel.IsOpen = false;
- }
- else
- {
- var activeTab = "Chat";
- if (TabCowork?.IsChecked == true) activeTab = "Cowork";
- else if (TabCode?.IsChecked == true) activeTab = "Code";
-
- SettingsPanel.LoadFromSettings(_settings, activeTab);
- SettingsPanel.CloseRequested -= OnSettingsPanelClose;
- SettingsPanel.CloseRequested += OnSettingsPanelClose;
- SettingsPanel.IsOpen = true;
- }
- }
-
- private void OnSettingsPanelClose(object? sender, EventArgs e)
- {
- SettingsPanel.IsOpen = false;
- }
}
diff --git a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
index 1ada39d..7920b87 100644
--- a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
+++ b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
@@ -706,400 +706,4 @@ public partial class ChatWindow
win.Content = content;
win.Show();
}
-
- // ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
-
- private DateTime _progressStartTime;
- private DispatcherTimer? _progressElapsedTimer;
-
- private void UpdateAgentProgressBar(AgentEvent evt)
- {
- switch (evt.Type)
- {
- case AgentEventType.Planning when evt.Steps is { Count: > 0 }:
- ShowStickyProgress(evt.Steps.Count);
- break;
-
- case AgentEventType.StepStart when evt.StepTotal > 0:
- UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary);
- break;
-
- case AgentEventType.Complete:
- HideStickyProgress();
- break;
- }
- }
-
- private void ShowStickyProgress(int totalSteps)
- {
- _progressStartTime = DateTime.Now;
- AgentProgressBar.Visibility = Visibility.Visible;
- ProgressIcon.Text = "\uE768"; // play
- ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})";
- ProgressPercent.Text = "0%";
- ProgressElapsed.Text = "0:00";
- ProgressFill.Width = 0;
-
- // 경과 시간 타이머
- _progressElapsedTimer?.Stop();
- _progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
- _progressElapsedTimer.Tick += (_, _) =>
- {
- var elapsed = DateTime.Now - _progressStartTime;
- ProgressElapsed.Text = elapsed.TotalHours >= 1
- ? elapsed.ToString(@"h\:mm\:ss")
- : elapsed.ToString(@"m\:ss");
- };
- _progressElapsedTimer.Start();
- }
-
- private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription)
- {
- if (AgentProgressBar.Visibility != Visibility.Visible) return;
-
- var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0;
- ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})";
- ProgressPercent.Text = $"{(int)(pct * 100)}%";
-
- // 프로그레스 바 너비 애니메이션
- var parentBorder = ProgressFill.Parent as Border;
- if (parentBorder != null)
- {
- var targetWidth = parentBorder.ActualWidth * pct;
- var anim = new System.Windows.Media.Animation.DoubleAnimation(
- ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300))
- {
- EasingFunction = new System.Windows.Media.Animation.QuadraticEase(),
- };
- ProgressFill.BeginAnimation(WidthProperty, anim);
- }
- }
-
- private void HideStickyProgress()
- {
- _progressElapsedTimer?.Stop();
- _progressElapsedTimer = null;
-
- if (AgentProgressBar.Visibility != Visibility.Visible) return;
-
- // 완료 표시 후 페이드아웃
- ProgressIcon.Text = "\uE930"; // check
- ProgressStepLabel.Text = "작업 완료";
- ProgressPercent.Text = "100%";
-
- // 프로그레스 바 100%
- var parentBorder = ProgressFill.Parent as Border;
- if (parentBorder != null)
- {
- var anim = new System.Windows.Media.Animation.DoubleAnimation(
- ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200));
- ProgressFill.BeginAnimation(WidthProperty, anim);
- }
-
- // 3초 후 숨기기
- var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
- hideTimer.Tick += (_, _) =>
- {
- hideTimer.Stop();
- var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
- fadeOut.Completed += (_, _) =>
- {
- AgentProgressBar.Visibility = Visibility.Collapsed;
- AgentProgressBar.Opacity = 1;
- ProgressFill.BeginAnimation(WidthProperty, null);
- ProgressFill.Width = 0;
- };
- AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut);
- };
- hideTimer.Start();
- }
-
- // ─── 파일 탐색기 ──────────────────────────────────────────────────────
-
- private static readonly HashSet _ignoredDirs = new(StringComparer.OrdinalIgnoreCase)
- {
- "bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
- "__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
- ".cache", ".next", ".nuxt", "coverage", ".terraform",
- };
-
- private DispatcherTimer? _fileBrowserRefreshTimer;
-
- private void ToggleFileBrowser()
- {
- if (FileBrowserPanel.Visibility == Visibility.Visible)
- {
- FileBrowserPanel.Visibility = Visibility.Collapsed;
- Llm.ShowFileBrowser = false;
- }
- else
- {
- FileBrowserPanel.Visibility = Visibility.Visible;
- Llm.ShowFileBrowser = true;
- BuildFileTree();
- }
- _settings.Save();
- }
-
- private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
-
- private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e)
- {
- var folder = GetCurrentWorkFolder();
- if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return;
- try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch (Exception) { /* 폴더 열기 실패 */ }
- }
-
- private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e)
- {
- FileBrowserPanel.Visibility = Visibility.Collapsed;
- }
-
- private void BuildFileTree()
- {
- FileTreeView.Items.Clear();
- var folder = GetCurrentWorkFolder();
- if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder))
- {
- FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
- return;
- }
-
- FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}";
- var count = 0;
- PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
- }
-
- private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count)
- {
- if (depth > 4 || count > 200) return;
-
- // 디렉터리
- try
- {
- foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
- {
- if (count > 200) break;
- if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue;
-
- count++;
- var dirItem = new TreeViewItem
- {
- Header = CreateFileTreeHeader("\uED25", subDir.Name, null),
- Tag = subDir.FullName,
- IsExpanded = depth < 1,
- };
-
- // 지연 로딩: 더미 자식 → 펼칠 때 실제 로드
- if (depth < 3)
- {
- dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미
- var capturedDir = subDir;
- var capturedDepth = depth;
- dirItem.Expanded += (s, _) =>
- {
- if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
- {
- ti.Items.Clear();
- int c = 0;
- PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
- }
- };
- }
- else
- {
- PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count);
- }
-
- items.Add(dirItem);
- }
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
-
- // 파일
- try
- {
- foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
- {
- if (count > 200) break;
- count++;
-
- var ext = file.Extension.ToLowerInvariant();
- var icon = GetFileIcon(ext);
- var size = FormatFileSize(file.Length);
-
- var fileItem = new TreeViewItem
- {
- Header = CreateFileTreeHeader(icon, file.Name, size),
- Tag = file.FullName,
- };
-
- // 더블클릭 → 프리뷰
- var capturedPath = file.FullName;
- fileItem.MouseDoubleClick += (s, e) =>
- {
- e.Handled = true;
- TryShowPreview(capturedPath);
- };
-
- // 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음)
- fileItem.MouseRightButtonUp += (s, e) =>
- {
- e.Handled = true;
- if (s is TreeViewItem ti) ti.IsSelected = true;
- ShowFileTreeContextMenu(capturedPath);
- };
-
- items.Add(fileItem);
- }
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- }
-
- private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText)
- {
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
- sp.Children.Add(new TextBlock
- {
- Text = icon,
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 11,
- Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 5, 0),
- });
- sp.Children.Add(new TextBlock
- {
- Text = name,
- FontSize = 11.5,
- VerticalAlignment = VerticalAlignment.Center,
- });
- if (sizeText != null)
- {
- sp.Children.Add(new TextBlock
- {
- Text = $" {sizeText}",
- FontSize = 10,
- Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)),
- VerticalAlignment = VerticalAlignment.Center,
- });
- }
- return sp;
- }
-
- private static string GetFileIcon(string ext) => ext switch
- {
- ".html" or ".htm" => "\uEB41",
- ".xlsx" or ".xls" => "\uE9F9",
- ".docx" or ".doc" => "\uE8A5",
- ".pdf" => "\uEA90",
- ".csv" => "\uE80A",
- ".md" => "\uE70B",
- ".json" or ".xml" => "\uE943",
- ".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F",
- ".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943",
- ".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756",
- ".txt" or ".log" => "\uE8A5",
- _ => "\uE7C3",
- };
-
- private static string FormatFileSize(long bytes) => bytes switch
- {
- < 1024 => $"{bytes} B",
- < 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
- _ => $"{bytes / (1024.0 * 1024.0):F1} MB",
- };
-
- private void ShowFileTreeContextMenu(string filePath)
- {
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var hoverBg = ThemeResourceHelper.Hint(this);
- var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C));
-
- var (popup, panel) = PopupMenuHelper.Create(this, this, PlacementMode.MousePoint, minWidth: 200);
-
- void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null)
- => panel.Children.Add(PopupMenuHelper.MenuItem(label, labelColor ?? primaryText, hoverBg,
- () => { popup.IsOpen = false; action(); },
- icon: icon, iconColor: iconColor ?? secondaryText, fontSize: 12.5));
-
- void AddSep() => panel.Children.Add(PopupMenuHelper.Separator());
-
- var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
- if (_previewableExtensions.Contains(ext))
- AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
-
- AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
- {
- try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch (Exception) { /* 파일 열기 실패 */ }
- });
- AddItem("\uED25", "폴더에서 보기", () =>
- {
- try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch (Exception) { /* 탐색기 열기 실패 */ }
- });
- AddItem("\uE8C8", "경로 복사", () =>
- {
- try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch (Exception) { /* 클립보드 접근 실패 */ }
- });
-
- AddSep();
-
- // 이름 변경
- AddItem("\uE8AC", "이름 변경", () =>
- {
- var dir = System.IO.Path.GetDirectoryName(filePath) ?? "";
- var oldName = System.IO.Path.GetFileName(filePath);
- var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
- if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
- {
- var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim());
- try
- {
- System.IO.File.Move(filePath, newPath);
- BuildFileTree();
- ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
- }
- catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); }
- }
- });
-
- // 삭제
- AddItem("\uE74D", "삭제", () =>
- {
- var result = MessageBox.Show(
- $"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}",
- "파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
- if (result == MessageBoxResult.Yes)
- {
- try
- {
- System.IO.File.Delete(filePath);
- BuildFileTree();
- ShowToast("파일 삭제됨");
- }
- catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); }
- }
- }, dangerBrush, dangerBrush);
-
- // Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음
- Dispatcher.BeginInvoke(() => { popup.IsOpen = true; },
- System.Windows.Threading.DispatcherPriority.Input);
- }
-
- /// 에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.
- private void RefreshFileTreeIfVisible()
- {
- if (FileBrowserPanel.Visibility != Visibility.Visible) return;
-
- // 디바운스: 500ms 내 중복 호출 방지
- _fileBrowserRefreshTimer?.Stop();
- _fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
- _fileBrowserRefreshTimer.Tick += (_, _) =>
- {
- _fileBrowserRefreshTimer.Stop();
- BuildFileTree();
- };
- _fileBrowserRefreshTimer.Start();
- }
}
diff --git a/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs b/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs
index b2e1a1b..fc0dd9d 100644
--- a/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs
+++ b/src/AxCopilot/Views/SettingsWindow.AgentConfig.cs
@@ -605,598 +605,4 @@ public partial class SettingsWindow
app?.MemoryService?.Clear();
CustomMessageBox.Show("에이전트 메모리가 초기화되었습니다.", "완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
-
- // ─── 에이전트 훅 관리 ─────────────────────────────────────────────────
-
- private void AddHookBtn_Click(object sender, MouseButtonEventArgs e)
- {
- ShowHookEditDialog(null, -1);
- }
-
- /// 플레이스홀더(워터마크) TextBlock 생성 헬퍼.
- private static TextBlock CreatePlaceholder(string text, Brush foreground, string? currentValue)
- {
- return new TextBlock
- {
- Text = text,
- FontSize = 13,
- Foreground = foreground,
- Opacity = 0.45,
- IsHitTestVisible = false,
- VerticalAlignment = VerticalAlignment.Center,
- Padding = new Thickness(14, 8, 14, 8),
- Visibility = string.IsNullOrEmpty(currentValue) ? Visibility.Visible : Visibility.Collapsed,
- };
- }
-
- private void ShowHookEditDialog(Models.AgentHookEntry? existing, int index)
- {
- var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
- var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
- var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
- var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60));
- var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40));
- var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
-
- var isNew = existing == null;
- var dlg = new Window
- {
- Title = isNew ? "훅 추가" : "훅 편집",
- Width = 420, SizeToContent = SizeToContent.Height,
- WindowStartupLocation = WindowStartupLocation.CenterOwner,
- Owner = this, ResizeMode = ResizeMode.NoResize,
- WindowStyle = WindowStyle.None, AllowsTransparency = true,
- Background = Brushes.Transparent,
- };
-
- var border = new Border
- {
- Background = bgBrush, CornerRadius = new CornerRadius(12),
- BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(20),
- };
- var stack = new StackPanel();
-
- // 제목
- stack.Children.Add(new TextBlock
- {
- Text = isNew ? "\u2699 훅 추가" : "\u2699 훅 편집",
- FontSize = 15, FontWeight = FontWeights.SemiBold,
- Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 14),
- });
-
- // ESC 키로 닫기
- dlg.KeyDown += (_, e) => { if (e.Key == System.Windows.Input.Key.Escape) dlg.Close(); };
-
- // 훅 이름
- stack.Children.Add(new TextBlock { Text = "훅 이름", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 4) });
- var nameBox = new TextBox
- {
- Text = existing?.Name ?? "", FontSize = 13,
- Foreground = fgBrush, Background = itemBg,
- BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
- };
- var nameHolder = CreatePlaceholder("예: 코드 리뷰 후 알림", subFgBrush, existing?.Name);
- nameBox.TextChanged += (_, _) => nameHolder.Visibility = string.IsNullOrEmpty(nameBox.Text) ? Visibility.Visible : Visibility.Collapsed;
- var nameGrid = new Grid();
- nameGrid.Children.Add(nameBox);
- nameGrid.Children.Add(nameHolder);
- stack.Children.Add(nameGrid);
-
- // 대상 도구
- stack.Children.Add(new TextBlock { Text = "대상 도구 (* = 모든 도구)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
- var toolBox = new TextBox
- {
- Text = existing?.ToolName ?? "*", FontSize = 13,
- Foreground = fgBrush, Background = itemBg,
- BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
- };
- var toolHolder = CreatePlaceholder("예: file_write, grep_tool", subFgBrush, existing?.ToolName ?? "*");
- toolBox.TextChanged += (_, _) => toolHolder.Visibility = string.IsNullOrEmpty(toolBox.Text) ? Visibility.Visible : Visibility.Collapsed;
- var toolGrid = new Grid();
- toolGrid.Children.Add(toolBox);
- toolGrid.Children.Add(toolHolder);
- stack.Children.Add(toolGrid);
-
- // 타이밍
- stack.Children.Add(new TextBlock { Text = "실행 타이밍", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
- var timingPanel = new StackPanel { Orientation = Orientation.Horizontal };
- var preRadio = new RadioButton { Content = "Pre (실행 전)", Foreground = fgBrush, FontSize = 13, Margin = new Thickness(0, 0, 16, 0), IsChecked = (existing?.Timing ?? "post") == "pre" };
- var postRadio = new RadioButton { Content = "Post (실행 후)", Foreground = fgBrush, FontSize = 13, IsChecked = (existing?.Timing ?? "post") != "pre" };
- timingPanel.Children.Add(preRadio);
- timingPanel.Children.Add(postRadio);
- stack.Children.Add(timingPanel);
-
- // 스크립트 경로
- stack.Children.Add(new TextBlock { Text = "스크립트 경로 (.bat / .cmd / .ps1)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
- var pathGrid = new Grid();
- pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- var pathInnerGrid = new Grid();
- var pathBox = new TextBox
- {
- Text = existing?.ScriptPath ?? "", FontSize = 13,
- Foreground = fgBrush, Background = itemBg,
- BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
- };
- var pathHolder = CreatePlaceholder("예: C:\\scripts\\review-notify.bat", subFgBrush, existing?.ScriptPath);
- pathBox.TextChanged += (_, _) => pathHolder.Visibility = string.IsNullOrEmpty(pathBox.Text) ? Visibility.Visible : Visibility.Collapsed;
- pathInnerGrid.Children.Add(pathBox);
- pathInnerGrid.Children.Add(pathHolder);
- Grid.SetColumn(pathInnerGrid, 0);
- pathGrid.Children.Add(pathInnerGrid);
-
- var browseBtn = new Border
- {
- Background = itemBg, CornerRadius = new CornerRadius(6),
- Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(6, 0, 0, 0),
- Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center,
- };
- browseBtn.Child = new TextBlock { Text = "...", FontSize = 13, Foreground = accentBrush };
- browseBtn.MouseLeftButtonUp += (_, _) =>
- {
- var ofd = new Microsoft.Win32.OpenFileDialog
- {
- Filter = "스크립트 파일|*.bat;*.cmd;*.ps1|모든 파일|*.*",
- Title = "훅 스크립트 선택",
- };
- if (ofd.ShowDialog() == true) pathBox.Text = ofd.FileName;
- };
- Grid.SetColumn(browseBtn, 1);
- pathGrid.Children.Add(browseBtn);
- stack.Children.Add(pathGrid);
-
- // 추가 인수
- stack.Children.Add(new TextBlock { Text = "추가 인수 (선택)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
- var argsBox = new TextBox
- {
- Text = existing?.Arguments ?? "", FontSize = 13,
- Foreground = fgBrush, Background = itemBg,
- BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
- };
- var argsHolder = CreatePlaceholder("예: --verbose --output log.txt", subFgBrush, existing?.Arguments);
- argsBox.TextChanged += (_, _) => argsHolder.Visibility = string.IsNullOrEmpty(argsBox.Text) ? Visibility.Visible : Visibility.Collapsed;
- var argsGrid = new Grid();
- argsGrid.Children.Add(argsBox);
- argsGrid.Children.Add(argsHolder);
- stack.Children.Add(argsGrid);
-
- // 버튼 행
- var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
- var cancelBorder = new Border
- {
- Background = itemBg, CornerRadius = new CornerRadius(8),
- Padding = new Thickness(16, 8, 16, 8), Margin = new Thickness(0, 0, 8, 0), Cursor = Cursors.Hand,
- };
- cancelBorder.Child = new TextBlock { Text = "취소", FontSize = 13, Foreground = subFgBrush };
- cancelBorder.MouseLeftButtonUp += (_, _) => dlg.Close();
- btnRow.Children.Add(cancelBorder);
-
- var saveBorder = new Border
- {
- Background = accentBrush, CornerRadius = new CornerRadius(8),
- Padding = new Thickness(16, 8, 16, 8), Cursor = Cursors.Hand,
- };
- saveBorder.Child = new TextBlock { Text = isNew ? "추가" : "저장", FontSize = 13, Foreground = Brushes.White, FontWeight = FontWeights.SemiBold };
- saveBorder.MouseLeftButtonUp += (_, _) =>
- {
- if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(pathBox.Text))
- {
- CustomMessageBox.Show("훅 이름과 스크립트 경로를 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
- return;
- }
- var entry = new Models.AgentHookEntry
- {
- Name = nameBox.Text.Trim(),
- ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(),
- Timing = preRadio.IsChecked == true ? "pre" : "post",
- ScriptPath = pathBox.Text.Trim(),
- Arguments = argsBox.Text.Trim(),
- Enabled = existing?.Enabled ?? true,
- };
-
- var hooks = _vm.Service.Settings.Llm.AgentHooks;
- if (isNew)
- hooks.Add(entry);
- else if (index >= 0 && index < hooks.Count)
- hooks[index] = entry;
-
- BuildHookCards();
- dlg.Close();
- };
- btnRow.Children.Add(saveBorder);
- stack.Children.Add(btnRow);
-
- border.Child = stack;
- dlg.Content = border;
- dlg.ShowDialog();
- }
-
- private void BuildHookCards()
- {
- if (HookListPanel == null) return;
- HookListPanel.Children.Clear();
-
- var hooks = _vm.Service.Settings.Llm.AgentHooks;
- var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
- var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
- var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
-
- for (int i = 0; i < hooks.Count; i++)
- {
- var hook = hooks[i];
- var idx = i;
-
- var card = new Border
- {
- Background = TryFindResource("ItemBackground") as Brush ?? Brushes.LightGray,
- CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 8, 10, 8),
- Margin = new Thickness(0, 0, 0, 4),
- };
-
- var grid = new Grid();
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 토글
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 정보
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 편집
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 삭제
-
- // 토글
- var toggle = new CheckBox
- {
- IsChecked = hook.Enabled,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 8, 0),
- Style = TryFindResource("ToggleSwitch") as Style,
- };
- var capturedHook = hook;
- toggle.Checked += (_, _) => capturedHook.Enabled = true;
- toggle.Unchecked += (_, _) => capturedHook.Enabled = false;
- Grid.SetColumn(toggle, 0);
- grid.Children.Add(toggle);
-
- // 정보
- var info = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
- var timingBadge = hook.Timing == "pre" ? "PRE" : "POST";
- var timingColor = hook.Timing == "pre" ? "#FF9800" : "#4CAF50";
- var headerPanel = new StackPanel { Orientation = Orientation.Horizontal };
- headerPanel.Children.Add(new TextBlock
- {
- Text = hook.Name, FontSize = 13, FontWeight = FontWeights.SemiBold,
- Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center,
- });
- headerPanel.Children.Add(new Border
- {
- Background = ThemeResourceHelper.HexBrush(timingColor),
- CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1),
- Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center,
- Child = new TextBlock { Text = timingBadge, FontSize = 9, Foreground = Brushes.White, FontWeight = FontWeights.Bold },
- });
- if (hook.ToolName != "*")
- {
- headerPanel.Children.Add(new Border
- {
- Background = new SolidColorBrush(Color.FromArgb(40, 100, 100, 255)),
- CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1),
- Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center,
- Child = new TextBlock { Text = hook.ToolName, FontSize = 9, Foreground = accentBrush },
- });
- }
- info.Children.Add(headerPanel);
- info.Children.Add(new TextBlock
- {
- Text = System.IO.Path.GetFileName(hook.ScriptPath),
- FontSize = 11, Foreground = secondaryText,
- TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = 200,
- });
- Grid.SetColumn(info, 1);
- grid.Children.Add(info);
-
- // 편집 버튼
- var editBtn = new Border
- {
- Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(4, 0, 4, 0), Padding = new Thickness(6),
- };
- editBtn.Child = new TextBlock
- {
- Text = "\uE70F", FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 12, Foreground = secondaryText,
- };
- editBtn.MouseLeftButtonUp += (_, _) => ShowHookEditDialog(hooks[idx], idx);
- Grid.SetColumn(editBtn, 2);
- grid.Children.Add(editBtn);
-
- // 삭제 버튼
- var delBtn = new Border
- {
- Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 0, 0), Padding = new Thickness(6),
- };
- delBtn.Child = new TextBlock
- {
- Text = "\uE74D", FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)),
- };
- delBtn.MouseLeftButtonUp += (_, _) =>
- {
- hooks.RemoveAt(idx);
- BuildHookCards();
- };
- Grid.SetColumn(delBtn, 3);
- grid.Children.Add(delBtn);
-
- card.Child = grid;
- HookListPanel.Children.Add(card);
- }
- }
-
- // ─── MCP 서버 관리 ─────────────────────────────────────────────────
- private void BtnAddMcpServer_Click(object sender, RoutedEventArgs e)
- {
- var dlg = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server");
- dlg.Owner = this;
- if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.ResponseText)) return;
-
- var name = dlg.ResponseText.Trim();
- var cmdDlg = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @anthropic/mcp-server");
- cmdDlg.Owner = this;
- if (cmdDlg.ShowDialog() != true || string.IsNullOrWhiteSpace(cmdDlg.ResponseText)) return;
-
- var entry = new Models.McpServerEntry { Name = name, Command = cmdDlg.ResponseText.Trim(), Enabled = true };
- _vm.Service.Settings.Llm.McpServers.Add(entry);
- BuildMcpServerCards();
- }
-
- private void BuildMcpServerCards()
- {
- if (McpServerListPanel == null) return;
- McpServerListPanel.Children.Clear();
-
- var servers = _vm.Service.Settings.Llm.McpServers;
- var primaryText = TryFindResource("PrimaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Black;
- var secondaryText = TryFindResource("SecondaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Gray;
- var accentBrush = TryFindResource("AccentColor") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Blue;
-
- for (int i = 0; i < servers.Count; i++)
- {
- var srv = servers[i];
- var idx = i;
-
- var card = new Border
- {
- Background = TryFindResource("ItemBackground") as System.Windows.Media.Brush,
- CornerRadius = new CornerRadius(10),
- Padding = new Thickness(14, 10, 14, 10),
- Margin = new Thickness(0, 4, 0, 0),
- BorderBrush = TryFindResource("BorderColor") as System.Windows.Media.Brush,
- BorderThickness = new Thickness(1),
- };
-
- var grid = new Grid();
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
-
- var info = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
- info.Children.Add(new TextBlock
- {
- Text = srv.Name, FontSize = 13.5, FontWeight = FontWeights.SemiBold, Foreground = primaryText,
- });
- var detailSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) };
- detailSp.Children.Add(new Border
- {
- Background = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray,
- CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 1, 6, 1), Margin = new Thickness(0, 0, 8, 0), Opacity = 0.8,
- Child = new TextBlock { Text = srv.Enabled ? "활성" : "비활성", FontSize = 10, Foreground = System.Windows.Media.Brushes.White, FontWeight = FontWeights.SemiBold },
- });
- detailSp.Children.Add(new TextBlock
- {
- Text = $"{srv.Command} {string.Join(" ", srv.Args)}", FontSize = 11,
- Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center,
- MaxWidth = 300, TextTrimming = TextTrimming.CharacterEllipsis,
- });
- info.Children.Add(detailSp);
- Grid.SetColumn(info, 0);
- grid.Children.Add(info);
-
- var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
-
- // 활성/비활성 토글
- var toggleBtn = new Button
- {
- Content = srv.Enabled ? "\uE73E" : "\uE711",
- FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
- FontSize = 12, ToolTip = srv.Enabled ? "비활성화" : "활성화",
- Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
- Foreground = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray,
- Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand,
- };
- toggleBtn.Click += (_, _) => { servers[idx].Enabled = !servers[idx].Enabled; BuildMcpServerCards(); };
- btnPanel.Children.Add(toggleBtn);
-
- // 삭제
- var delBtn = new Button
- {
- Content = "\uE74D", FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
- FontSize = 12, ToolTip = "삭제",
- Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
- Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xDD, 0x44, 0x44)),
- Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand,
- };
- delBtn.Click += (_, _) => { servers.RemoveAt(idx); BuildMcpServerCards(); };
- btnPanel.Children.Add(delBtn);
-
- Grid.SetColumn(btnPanel, 1);
- grid.Children.Add(btnPanel);
- card.Child = grid;
- McpServerListPanel.Children.Add(card);
- }
- }
-
- // ─── 감사 로그 폴더 열기 ────────────────────────────────────────────
- private void BtnOpenAuditLog_Click(object sender, RoutedEventArgs e)
- {
- try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { }
- }
-
- // ─── 폴백/MCP 텍스트 박스 로드/저장 ───────────────────────────────────
- private void BuildFallbackModelsPanel()
- {
- if (FallbackModelsPanel == null) return;
- FallbackModelsPanel.Children.Clear();
-
- var llm = _vm.Service.Settings.Llm;
- var fallbacks = llm.FallbackModels;
- var toggleStyle = TryFindResource("ToggleSwitch") as Style;
-
- // 서비스별로 모델 수집 (순서 고정: Ollama → vLLM → Gemini → Claude)
- var sections = new (string Service, string Label, string Color, List Models)[]
- {
- ("ollama", "Ollama", "#107C10", new()),
- ("vllm", "vLLM", "#0078D4", new()),
- ("gemini", "Gemini", "#4285F4", new()),
- ("claude", "Claude", "#8B5CF6", new()),
- };
-
- // RegisteredModels → ViewModel과 AppSettings 양쪽에서 수집 (저장 전에도 반영)
- // 1) ViewModel의 RegisteredModels (UI에서 방금 추가한 것 포함)
- foreach (var row in _vm.RegisteredModels)
- {
- var svc = (row.Service ?? "").ToLowerInvariant();
- var modelName = !string.IsNullOrEmpty(row.Alias) ? row.Alias : row.EncryptedModelName;
- var section = sections.FirstOrDefault(s => s.Service == svc);
- if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName))
- section.Models.Add(modelName);
- }
- // 2) AppSettings의 RegisteredModels (기존 저장된 것 — ViewModel에 없는 경우 보완)
- foreach (var m in llm.RegisteredModels)
- {
- var svc = (m.Service ?? "").ToLowerInvariant();
- var modelName = !string.IsNullOrEmpty(m.Alias) ? m.Alias : m.EncryptedModelName;
- var section = sections.FirstOrDefault(s => s.Service == svc);
- if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName))
- section.Models.Add(modelName);
- }
-
- // 현재 활성 모델 추가 (중복 제거)
- if (!string.IsNullOrEmpty(llm.OllamaModel) && !sections[0].Models.Contains(llm.OllamaModel))
- sections[0].Models.Add(llm.OllamaModel);
- if (!string.IsNullOrEmpty(llm.VllmModel) && !sections[1].Models.Contains(llm.VllmModel))
- sections[1].Models.Add(llm.VllmModel);
-
- // Gemini/Claude 고정 모델 목록
- foreach (var gm in new[] { "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash" })
- if (!sections[2].Models.Contains(gm)) sections[2].Models.Add(gm);
- foreach (var cm in new[] { "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5", "claude-sonnet-4-5" })
- if (!sections[3].Models.Contains(cm)) sections[3].Models.Add(cm);
-
- // 렌더링 — 모델이 없는 섹션도 헤더는 표시
- foreach (var (service, svcLabel, svcColor, models) in sections)
- {
- FallbackModelsPanel.Children.Add(new TextBlock
- {
- Text = svcLabel,
- FontSize = 11, FontWeight = FontWeights.SemiBold,
- Foreground = BrushFromHex(svcColor),
- Margin = new Thickness(0, 8, 0, 4),
- });
-
- if (models.Count == 0)
- {
- FallbackModelsPanel.Children.Add(new TextBlock
- {
- Text = "등록된 모델 없음",
- FontSize = 11, Foreground = Brushes.Gray, FontStyle = FontStyles.Italic,
- Margin = new Thickness(8, 2, 0, 4),
- });
- continue;
- }
-
- foreach (var modelName in models)
- {
- var fullKey = $"{service}:{modelName}";
-
- var row = new Grid { Margin = new Thickness(8, 2, 0, 2) };
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
-
- var label = new TextBlock
- {
- Text = modelName, FontSize = 12, FontFamily = ThemeResourceHelper.ConsolasCourierNew,
- VerticalAlignment = VerticalAlignment.Center,
- Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black,
- };
- Grid.SetColumn(label, 0);
- row.Children.Add(label);
-
- var captured = fullKey;
- var cb = new CheckBox
- {
- IsChecked = fallbacks.Contains(fullKey, StringComparer.OrdinalIgnoreCase),
- HorizontalAlignment = HorizontalAlignment.Right,
- VerticalAlignment = VerticalAlignment.Center,
- };
- if (toggleStyle != null) cb.Style = toggleStyle;
- cb.Checked += (_, _) =>
- {
- if (!fallbacks.Contains(captured)) fallbacks.Add(captured);
- FallbackModelsBox.Text = string.Join("\n", fallbacks);
- };
- cb.Unchecked += (_, _) =>
- {
- fallbacks.RemoveAll(x => x.Equals(captured, StringComparison.OrdinalIgnoreCase));
- FallbackModelsBox.Text = string.Join("\n", fallbacks);
- };
- Grid.SetColumn(cb, 1);
- row.Children.Add(cb);
-
- FallbackModelsPanel.Children.Add(row);
- }
- }
- }
-
- private void LoadAdvancedSettings()
- {
- var llm = _vm.Service.Settings.Llm;
- if (FallbackModelsBox != null)
- FallbackModelsBox.Text = string.Join("\n", llm.FallbackModels);
- BuildFallbackModelsPanel();
- if (McpServersBox != null)
- {
- try
- {
- var json = System.Text.Json.JsonSerializer.Serialize(llm.McpServers,
- new System.Text.Json.JsonSerializerOptions { WriteIndented = true,
- Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
- McpServersBox.Text = json;
- }
- catch (Exception) { McpServersBox.Text = "[]"; }
- }
- BuildMcpServerCards();
- BuildHookCards();
- }
-
- private void SaveAdvancedSettings()
- {
- var llm = _vm.Service.Settings.Llm;
- if (FallbackModelsBox != null)
- {
- llm.FallbackModels = FallbackModelsBox.Text
- .Split('\n', StringSplitOptions.RemoveEmptyEntries)
- .Select(s => s.Trim())
- .Where(s => s.Length > 0)
- .ToList();
- }
- if (McpServersBox != null && !string.IsNullOrWhiteSpace(McpServersBox.Text))
- {
- try
- {
- llm.McpServers = System.Text.Json.JsonSerializer.Deserialize>(
- McpServersBox.Text) ?? new();
- }
- catch (Exception) { /* JSON 파싱 실패 시 기존 유지 */ }
- }
-
- // 도구 비활성 목록 저장
- if (_toolCardsLoaded)
- llm.DisabledTools = _disabledTools.ToList();
- }
}
diff --git a/src/AxCopilot/Views/SettingsWindow.AgentHooks.cs b/src/AxCopilot/Views/SettingsWindow.AgentHooks.cs
new file mode 100644
index 0000000..5cec6c1
--- /dev/null
+++ b/src/AxCopilot/Views/SettingsWindow.AgentHooks.cs
@@ -0,0 +1,605 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace AxCopilot.Views;
+
+public partial class SettingsWindow
+{
+ // ─── 에이전트 훅 관리 ─────────────────────────────────────────────────
+
+ private void AddHookBtn_Click(object sender, MouseButtonEventArgs e)
+ {
+ ShowHookEditDialog(null, -1);
+ }
+
+ /// 플레이스홀더(워터마크) TextBlock 생성 헬퍼.
+ private static TextBlock CreatePlaceholder(string text, Brush foreground, string? currentValue)
+ {
+ return new TextBlock
+ {
+ Text = text,
+ FontSize = 13,
+ Foreground = foreground,
+ Opacity = 0.45,
+ IsHitTestVisible = false,
+ VerticalAlignment = VerticalAlignment.Center,
+ Padding = new Thickness(14, 8, 14, 8),
+ Visibility = string.IsNullOrEmpty(currentValue) ? Visibility.Visible : Visibility.Collapsed,
+ };
+ }
+
+ private void ShowHookEditDialog(Models.AgentHookEntry? existing, int index)
+ {
+ var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
+ var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var borderBrush = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x40, 0x40, 0x60));
+ var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40));
+ var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
+
+ var isNew = existing == null;
+ var dlg = new Window
+ {
+ Title = isNew ? "훅 추가" : "훅 편집",
+ Width = 420, SizeToContent = SizeToContent.Height,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Owner = this, ResizeMode = ResizeMode.NoResize,
+ WindowStyle = WindowStyle.None, AllowsTransparency = true,
+ Background = Brushes.Transparent,
+ };
+
+ var border = new Border
+ {
+ Background = bgBrush, CornerRadius = new CornerRadius(12),
+ BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(20),
+ };
+ var stack = new StackPanel();
+
+ // 제목
+ stack.Children.Add(new TextBlock
+ {
+ Text = isNew ? "\u2699 훅 추가" : "\u2699 훅 편집",
+ FontSize = 15, FontWeight = FontWeights.SemiBold,
+ Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 14),
+ });
+
+ // ESC 키로 닫기
+ dlg.KeyDown += (_, e) => { if (e.Key == System.Windows.Input.Key.Escape) dlg.Close(); };
+
+ // 훅 이름
+ stack.Children.Add(new TextBlock { Text = "훅 이름", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 4) });
+ var nameBox = new TextBox
+ {
+ Text = existing?.Name ?? "", FontSize = 13,
+ Foreground = fgBrush, Background = itemBg,
+ BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
+ };
+ var nameHolder = CreatePlaceholder("예: 코드 리뷰 후 알림", subFgBrush, existing?.Name);
+ nameBox.TextChanged += (_, _) => nameHolder.Visibility = string.IsNullOrEmpty(nameBox.Text) ? Visibility.Visible : Visibility.Collapsed;
+ var nameGrid = new Grid();
+ nameGrid.Children.Add(nameBox);
+ nameGrid.Children.Add(nameHolder);
+ stack.Children.Add(nameGrid);
+
+ // 대상 도구
+ stack.Children.Add(new TextBlock { Text = "대상 도구 (* = 모든 도구)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
+ var toolBox = new TextBox
+ {
+ Text = existing?.ToolName ?? "*", FontSize = 13,
+ Foreground = fgBrush, Background = itemBg,
+ BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
+ };
+ var toolHolder = CreatePlaceholder("예: file_write, grep_tool", subFgBrush, existing?.ToolName ?? "*");
+ toolBox.TextChanged += (_, _) => toolHolder.Visibility = string.IsNullOrEmpty(toolBox.Text) ? Visibility.Visible : Visibility.Collapsed;
+ var toolGrid = new Grid();
+ toolGrid.Children.Add(toolBox);
+ toolGrid.Children.Add(toolHolder);
+ stack.Children.Add(toolGrid);
+
+ // 타이밍
+ stack.Children.Add(new TextBlock { Text = "실행 타이밍", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
+ var timingPanel = new StackPanel { Orientation = Orientation.Horizontal };
+ var preRadio = new RadioButton { Content = "Pre (실행 전)", Foreground = fgBrush, FontSize = 13, Margin = new Thickness(0, 0, 16, 0), IsChecked = (existing?.Timing ?? "post") == "pre" };
+ var postRadio = new RadioButton { Content = "Post (실행 후)", Foreground = fgBrush, FontSize = 13, IsChecked = (existing?.Timing ?? "post") != "pre" };
+ timingPanel.Children.Add(preRadio);
+ timingPanel.Children.Add(postRadio);
+ stack.Children.Add(timingPanel);
+
+ // 스크립트 경로
+ stack.Children.Add(new TextBlock { Text = "스크립트 경로 (.bat / .cmd / .ps1)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
+ var pathGrid = new Grid();
+ pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ var pathInnerGrid = new Grid();
+ var pathBox = new TextBox
+ {
+ Text = existing?.ScriptPath ?? "", FontSize = 13,
+ Foreground = fgBrush, Background = itemBg,
+ BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
+ };
+ var pathHolder = CreatePlaceholder("예: C:\\scripts\\review-notify.bat", subFgBrush, existing?.ScriptPath);
+ pathBox.TextChanged += (_, _) => pathHolder.Visibility = string.IsNullOrEmpty(pathBox.Text) ? Visibility.Visible : Visibility.Collapsed;
+ pathInnerGrid.Children.Add(pathBox);
+ pathInnerGrid.Children.Add(pathHolder);
+ Grid.SetColumn(pathInnerGrid, 0);
+ pathGrid.Children.Add(pathInnerGrid);
+
+ var browseBtn = new Border
+ {
+ Background = itemBg, CornerRadius = new CornerRadius(6),
+ Padding = new Thickness(10, 6, 10, 6), Margin = new Thickness(6, 0, 0, 0),
+ Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center,
+ };
+ browseBtn.Child = new TextBlock { Text = "...", FontSize = 13, Foreground = accentBrush };
+ browseBtn.MouseLeftButtonUp += (_, _) =>
+ {
+ var ofd = new Microsoft.Win32.OpenFileDialog
+ {
+ Filter = "스크립트 파일|*.bat;*.cmd;*.ps1|모든 파일|*.*",
+ Title = "훅 스크립트 선택",
+ };
+ if (ofd.ShowDialog() == true) pathBox.Text = ofd.FileName;
+ };
+ Grid.SetColumn(browseBtn, 1);
+ pathGrid.Children.Add(browseBtn);
+ stack.Children.Add(pathGrid);
+
+ // 추가 인수
+ stack.Children.Add(new TextBlock { Text = "추가 인수 (선택)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
+ var argsBox = new TextBox
+ {
+ Text = existing?.Arguments ?? "", FontSize = 13,
+ Foreground = fgBrush, Background = itemBg,
+ BorderBrush = borderBrush, Padding = new Thickness(12, 8, 12, 8),
+ };
+ var argsHolder = CreatePlaceholder("예: --verbose --output log.txt", subFgBrush, existing?.Arguments);
+ argsBox.TextChanged += (_, _) => argsHolder.Visibility = string.IsNullOrEmpty(argsBox.Text) ? Visibility.Visible : Visibility.Collapsed;
+ var argsGrid = new Grid();
+ argsGrid.Children.Add(argsBox);
+ argsGrid.Children.Add(argsHolder);
+ stack.Children.Add(argsGrid);
+
+ // 버튼 행
+ var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
+ var cancelBorder = new Border
+ {
+ Background = itemBg, CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(16, 8, 16, 8), Margin = new Thickness(0, 0, 8, 0), Cursor = Cursors.Hand,
+ };
+ cancelBorder.Child = new TextBlock { Text = "취소", FontSize = 13, Foreground = subFgBrush };
+ cancelBorder.MouseLeftButtonUp += (_, _) => dlg.Close();
+ btnRow.Children.Add(cancelBorder);
+
+ var saveBorder = new Border
+ {
+ Background = accentBrush, CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(16, 8, 16, 8), Cursor = Cursors.Hand,
+ };
+ saveBorder.Child = new TextBlock { Text = isNew ? "추가" : "저장", FontSize = 13, Foreground = Brushes.White, FontWeight = FontWeights.SemiBold };
+ saveBorder.MouseLeftButtonUp += (_, _) =>
+ {
+ if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(pathBox.Text))
+ {
+ CustomMessageBox.Show("훅 이름과 스크립트 경로를 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ var entry = new Models.AgentHookEntry
+ {
+ Name = nameBox.Text.Trim(),
+ ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(),
+ Timing = preRadio.IsChecked == true ? "pre" : "post",
+ ScriptPath = pathBox.Text.Trim(),
+ Arguments = argsBox.Text.Trim(),
+ Enabled = existing?.Enabled ?? true,
+ };
+
+ var hooks = _vm.Service.Settings.Llm.AgentHooks;
+ if (isNew)
+ hooks.Add(entry);
+ else if (index >= 0 && index < hooks.Count)
+ hooks[index] = entry;
+
+ BuildHookCards();
+ dlg.Close();
+ };
+ btnRow.Children.Add(saveBorder);
+ stack.Children.Add(btnRow);
+
+ border.Child = stack;
+ dlg.Content = border;
+ dlg.ShowDialog();
+ }
+
+ private void BuildHookCards()
+ {
+ if (HookListPanel == null) return;
+ HookListPanel.Children.Clear();
+
+ var hooks = _vm.Service.Settings.Llm.AgentHooks;
+ var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
+ var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
+
+ for (int i = 0; i < hooks.Count; i++)
+ {
+ var hook = hooks[i];
+ var idx = i;
+
+ var card = new Border
+ {
+ Background = TryFindResource("ItemBackground") as Brush ?? Brushes.LightGray,
+ CornerRadius = new CornerRadius(8), Padding = new Thickness(10, 8, 10, 8),
+ Margin = new Thickness(0, 0, 0, 4),
+ };
+
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 토글
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 정보
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 편집
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 삭제
+
+ // 토글
+ var toggle = new CheckBox
+ {
+ IsChecked = hook.Enabled,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0),
+ Style = TryFindResource("ToggleSwitch") as Style,
+ };
+ var capturedHook = hook;
+ toggle.Checked += (_, _) => capturedHook.Enabled = true;
+ toggle.Unchecked += (_, _) => capturedHook.Enabled = false;
+ Grid.SetColumn(toggle, 0);
+ grid.Children.Add(toggle);
+
+ // 정보
+ var info = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
+ var timingBadge = hook.Timing == "pre" ? "PRE" : "POST";
+ var timingColor = hook.Timing == "pre" ? "#FF9800" : "#4CAF50";
+ var headerPanel = new StackPanel { Orientation = Orientation.Horizontal };
+ headerPanel.Children.Add(new TextBlock
+ {
+ Text = hook.Name, FontSize = 13, FontWeight = FontWeights.SemiBold,
+ Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center,
+ });
+ headerPanel.Children.Add(new Border
+ {
+ Background = ThemeResourceHelper.HexBrush(timingColor),
+ CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1),
+ Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center,
+ Child = new TextBlock { Text = timingBadge, FontSize = 9, Foreground = Brushes.White, FontWeight = FontWeights.Bold },
+ });
+ if (hook.ToolName != "*")
+ {
+ headerPanel.Children.Add(new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(40, 100, 100, 255)),
+ CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 1, 5, 1),
+ Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center,
+ Child = new TextBlock { Text = hook.ToolName, FontSize = 9, Foreground = accentBrush },
+ });
+ }
+ info.Children.Add(headerPanel);
+ info.Children.Add(new TextBlock
+ {
+ Text = System.IO.Path.GetFileName(hook.ScriptPath),
+ FontSize = 11, Foreground = secondaryText,
+ TextTrimming = TextTrimming.CharacterEllipsis, MaxWidth = 200,
+ });
+ Grid.SetColumn(info, 1);
+ grid.Children.Add(info);
+
+ // 편집 버튼
+ var editBtn = new Border
+ {
+ Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 0, 4, 0), Padding = new Thickness(6),
+ };
+ editBtn.Child = new TextBlock
+ {
+ Text = "\uE70F", FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 12, Foreground = secondaryText,
+ };
+ editBtn.MouseLeftButtonUp += (_, _) => ShowHookEditDialog(hooks[idx], idx);
+ Grid.SetColumn(editBtn, 2);
+ grid.Children.Add(editBtn);
+
+ // 삭제 버튼
+ var delBtn = new Border
+ {
+ Cursor = Cursors.Hand, VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 0, 0), Padding = new Thickness(6),
+ };
+ delBtn.Child = new TextBlock
+ {
+ Text = "\uE74D", FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)),
+ };
+ delBtn.MouseLeftButtonUp += (_, _) =>
+ {
+ hooks.RemoveAt(idx);
+ BuildHookCards();
+ };
+ Grid.SetColumn(delBtn, 3);
+ grid.Children.Add(delBtn);
+
+ card.Child = grid;
+ HookListPanel.Children.Add(card);
+ }
+ }
+
+ // ─── MCP 서버 관리 ─────────────────────────────────────────────────
+ private void BtnAddMcpServer_Click(object sender, RoutedEventArgs e)
+ {
+ var dlg = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server");
+ dlg.Owner = this;
+ if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.ResponseText)) return;
+
+ var name = dlg.ResponseText.Trim();
+ var cmdDlg = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @anthropic/mcp-server");
+ cmdDlg.Owner = this;
+ if (cmdDlg.ShowDialog() != true || string.IsNullOrWhiteSpace(cmdDlg.ResponseText)) return;
+
+ var entry = new Models.McpServerEntry { Name = name, Command = cmdDlg.ResponseText.Trim(), Enabled = true };
+ _vm.Service.Settings.Llm.McpServers.Add(entry);
+ BuildMcpServerCards();
+ }
+
+ private void BuildMcpServerCards()
+ {
+ if (McpServerListPanel == null) return;
+ McpServerListPanel.Children.Clear();
+
+ var servers = _vm.Service.Settings.Llm.McpServers;
+ var primaryText = TryFindResource("PrimaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Black;
+ var secondaryText = TryFindResource("SecondaryText") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Gray;
+ var accentBrush = TryFindResource("AccentColor") as System.Windows.Media.Brush ?? System.Windows.Media.Brushes.Blue;
+
+ for (int i = 0; i < servers.Count; i++)
+ {
+ var srv = servers[i];
+ var idx = i;
+
+ var card = new Border
+ {
+ Background = TryFindResource("ItemBackground") as System.Windows.Media.Brush,
+ CornerRadius = new CornerRadius(10),
+ Padding = new Thickness(14, 10, 14, 10),
+ Margin = new Thickness(0, 4, 0, 0),
+ BorderBrush = TryFindResource("BorderColor") as System.Windows.Media.Brush,
+ BorderThickness = new Thickness(1),
+ };
+
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var info = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
+ info.Children.Add(new TextBlock
+ {
+ Text = srv.Name, FontSize = 13.5, FontWeight = FontWeights.SemiBold, Foreground = primaryText,
+ });
+ var detailSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) };
+ detailSp.Children.Add(new Border
+ {
+ Background = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray,
+ CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 1, 6, 1), Margin = new Thickness(0, 0, 8, 0), Opacity = 0.8,
+ Child = new TextBlock { Text = srv.Enabled ? "활성" : "비활성", FontSize = 10, Foreground = System.Windows.Media.Brushes.White, FontWeight = FontWeights.SemiBold },
+ });
+ detailSp.Children.Add(new TextBlock
+ {
+ Text = $"{srv.Command} {string.Join(" ", srv.Args)}", FontSize = 11,
+ Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center,
+ MaxWidth = 300, TextTrimming = TextTrimming.CharacterEllipsis,
+ });
+ info.Children.Add(detailSp);
+ Grid.SetColumn(info, 0);
+ grid.Children.Add(info);
+
+ var btnPanel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
+
+ // 활성/비활성 토글
+ var toggleBtn = new Button
+ {
+ Content = srv.Enabled ? "\uE73E" : "\uE711",
+ FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
+ FontSize = 12, ToolTip = srv.Enabled ? "비활성화" : "활성화",
+ Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
+ Foreground = srv.Enabled ? accentBrush : System.Windows.Media.Brushes.Gray,
+ Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand,
+ };
+ toggleBtn.Click += (_, _) => { servers[idx].Enabled = !servers[idx].Enabled; BuildMcpServerCards(); };
+ btnPanel.Children.Add(toggleBtn);
+
+ // 삭제
+ var delBtn = new Button
+ {
+ Content = "\uE74D", FontFamily = new System.Windows.Media.FontFamily("Segoe MDL2 Assets"),
+ FontSize = 12, ToolTip = "삭제",
+ Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
+ Foreground = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xDD, 0x44, 0x44)),
+ Padding = new Thickness(6, 4, 6, 4), Cursor = Cursors.Hand,
+ };
+ delBtn.Click += (_, _) => { servers.RemoveAt(idx); BuildMcpServerCards(); };
+ btnPanel.Children.Add(delBtn);
+
+ Grid.SetColumn(btnPanel, 1);
+ grid.Children.Add(btnPanel);
+ card.Child = grid;
+ McpServerListPanel.Children.Add(card);
+ }
+ }
+
+ // ─── 감사 로그 폴더 열기 ────────────────────────────────────────────
+ private void BtnOpenAuditLog_Click(object sender, RoutedEventArgs e)
+ {
+ try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { }
+ }
+
+ // ─── 폴백/MCP 텍스트 박스 로드/저장 ───────────────────────────────────
+ private void BuildFallbackModelsPanel()
+ {
+ if (FallbackModelsPanel == null) return;
+ FallbackModelsPanel.Children.Clear();
+
+ var llm = _vm.Service.Settings.Llm;
+ var fallbacks = llm.FallbackModels;
+ var toggleStyle = TryFindResource("ToggleSwitch") as Style;
+
+ // 서비스별로 모델 수집 (순서 고정: Ollama → vLLM → Gemini → Claude)
+ var sections = new (string Service, string Label, string Color, List Models)[]
+ {
+ ("ollama", "Ollama", "#107C10", new()),
+ ("vllm", "vLLM", "#0078D4", new()),
+ ("gemini", "Gemini", "#4285F4", new()),
+ ("claude", "Claude", "#8B5CF6", new()),
+ };
+
+ // RegisteredModels → ViewModel과 AppSettings 양쪽에서 수집 (저장 전에도 반영)
+ // 1) ViewModel의 RegisteredModels (UI에서 방금 추가한 것 포함)
+ foreach (var row in _vm.RegisteredModels)
+ {
+ var svc = (row.Service ?? "").ToLowerInvariant();
+ var modelName = !string.IsNullOrEmpty(row.Alias) ? row.Alias : row.EncryptedModelName;
+ var section = sections.FirstOrDefault(s => s.Service == svc);
+ if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName))
+ section.Models.Add(modelName);
+ }
+ // 2) AppSettings의 RegisteredModels (기존 저장된 것 — ViewModel에 없는 경우 보완)
+ foreach (var m in llm.RegisteredModels)
+ {
+ var svc = (m.Service ?? "").ToLowerInvariant();
+ var modelName = !string.IsNullOrEmpty(m.Alias) ? m.Alias : m.EncryptedModelName;
+ var section = sections.FirstOrDefault(s => s.Service == svc);
+ if (section.Models != null && !string.IsNullOrEmpty(modelName) && !section.Models.Contains(modelName))
+ section.Models.Add(modelName);
+ }
+
+ // 현재 활성 모델 추가 (중복 제거)
+ if (!string.IsNullOrEmpty(llm.OllamaModel) && !sections[0].Models.Contains(llm.OllamaModel))
+ sections[0].Models.Add(llm.OllamaModel);
+ if (!string.IsNullOrEmpty(llm.VllmModel) && !sections[1].Models.Contains(llm.VllmModel))
+ sections[1].Models.Add(llm.VllmModel);
+
+ // Gemini/Claude 고정 모델 목록
+ foreach (var gm in new[] { "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash" })
+ if (!sections[2].Models.Contains(gm)) sections[2].Models.Add(gm);
+ foreach (var cm in new[] { "claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5", "claude-sonnet-4-5" })
+ if (!sections[3].Models.Contains(cm)) sections[3].Models.Add(cm);
+
+ // 렌더링 — 모델이 없는 섹션도 헤더는 표시
+ foreach (var (service, svcLabel, svcColor, models) in sections)
+ {
+ FallbackModelsPanel.Children.Add(new TextBlock
+ {
+ Text = svcLabel,
+ FontSize = 11, FontWeight = FontWeights.SemiBold,
+ Foreground = BrushFromHex(svcColor),
+ Margin = new Thickness(0, 8, 0, 4),
+ });
+
+ if (models.Count == 0)
+ {
+ FallbackModelsPanel.Children.Add(new TextBlock
+ {
+ Text = "등록된 모델 없음",
+ FontSize = 11, Foreground = Brushes.Gray, FontStyle = FontStyles.Italic,
+ Margin = new Thickness(8, 2, 0, 4),
+ });
+ continue;
+ }
+
+ foreach (var modelName in models)
+ {
+ var fullKey = $"{service}:{modelName}";
+
+ var row = new Grid { Margin = new Thickness(8, 2, 0, 2) };
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+
+ var label = new TextBlock
+ {
+ Text = modelName, FontSize = 12, FontFamily = ThemeResourceHelper.ConsolasCourierNew,
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black,
+ };
+ Grid.SetColumn(label, 0);
+ row.Children.Add(label);
+
+ var captured = fullKey;
+ var cb = new CheckBox
+ {
+ IsChecked = fallbacks.Contains(fullKey, StringComparer.OrdinalIgnoreCase),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ if (toggleStyle != null) cb.Style = toggleStyle;
+ cb.Checked += (_, _) =>
+ {
+ if (!fallbacks.Contains(captured)) fallbacks.Add(captured);
+ FallbackModelsBox.Text = string.Join("\n", fallbacks);
+ };
+ cb.Unchecked += (_, _) =>
+ {
+ fallbacks.RemoveAll(x => x.Equals(captured, StringComparison.OrdinalIgnoreCase));
+ FallbackModelsBox.Text = string.Join("\n", fallbacks);
+ };
+ Grid.SetColumn(cb, 1);
+ row.Children.Add(cb);
+
+ FallbackModelsPanel.Children.Add(row);
+ }
+ }
+ }
+
+ private void LoadAdvancedSettings()
+ {
+ var llm = _vm.Service.Settings.Llm;
+ if (FallbackModelsBox != null)
+ FallbackModelsBox.Text = string.Join("\n", llm.FallbackModels);
+ BuildFallbackModelsPanel();
+ if (McpServersBox != null)
+ {
+ try
+ {
+ var json = System.Text.Json.JsonSerializer.Serialize(llm.McpServers,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
+ McpServersBox.Text = json;
+ }
+ catch (Exception) { McpServersBox.Text = "[]"; }
+ }
+ BuildMcpServerCards();
+ BuildHookCards();
+ }
+
+ private void SaveAdvancedSettings()
+ {
+ var llm = _vm.Service.Settings.Llm;
+ if (FallbackModelsBox != null)
+ {
+ llm.FallbackModels = FallbackModelsBox.Text
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(s => s.Trim())
+ .Where(s => s.Length > 0)
+ .ToList();
+ }
+ if (McpServersBox != null && !string.IsNullOrWhiteSpace(McpServersBox.Text))
+ {
+ try
+ {
+ llm.McpServers = System.Text.Json.JsonSerializer.Deserialize>(
+ McpServersBox.Text) ?? new();
+ }
+ catch (Exception) { /* JSON 파싱 실패 시 기존 유지 */ }
+ }
+
+ // 도구 비활성 목록 저장
+ if (_toolCardsLoaded)
+ llm.DisabledTools = _disabledTools.ToList();
+ }
+}
diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs
new file mode 100644
index 0000000..8b93ab0
--- /dev/null
+++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.Charts.cs
@@ -0,0 +1,667 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+using AxCopilot.Services.Agent;
+
+namespace AxCopilot.Views;
+
+public partial class WorkflowAnalyzerWindow
+{
+ // ─── 병목 분석 차트 렌더링 ─────────────────────────────────────
+
+ private void ClearBottleneckCharts()
+ {
+ WaterfallCanvas.Children.Clear();
+ ToolTimePanel.Children.Clear();
+ TokenTrendCanvas.Children.Clear();
+ }
+
+ private void RenderBottleneckCharts()
+ {
+ ClearBottleneckCharts();
+ RenderWaterfallChart();
+ RenderToolTimeChart();
+ RenderTokenTrendChart();
+ }
+
+ /// 워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색.
+ private void RenderWaterfallChart()
+ {
+ if (_waterfallEntries.Count == 0)
+ {
+ WaterfallCanvas.Height = 40;
+ var emptyMsg = new TextBlock
+ {
+ Text = "실행 데이터가 없습니다",
+ FontSize = 11,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ };
+ Canvas.SetLeft(emptyMsg, 10);
+ Canvas.SetTop(emptyMsg, 10);
+ WaterfallCanvas.Children.Add(emptyMsg);
+ return;
+ }
+
+ // 병목 판정: 평균 × 2.0 이상 = 빨강, × 1.5 이상 = 주황
+ var avgDuration = _waterfallEntries.Average(e => e.DurationMs);
+ var maxEndMs = _waterfallEntries.Max(e => e.StartMs + e.DurationMs);
+ if (maxEndMs <= 0) maxEndMs = 1;
+
+ var rowHeight = 22;
+ var labelWidth = 90.0;
+ var chartHeight = _waterfallEntries.Count * rowHeight + 10;
+ WaterfallCanvas.Height = Math.Max(chartHeight, 60);
+
+ var canvasWidth = WaterfallCanvas.ActualWidth > 0 ? WaterfallCanvas.ActualWidth : 480;
+ var barAreaWidth = canvasWidth - labelWidth - 60; // 우측에 시간 텍스트 공간
+
+ for (int i = 0; i < _waterfallEntries.Count; i++)
+ {
+ var entry = _waterfallEntries[i];
+ var y = i * rowHeight + 4;
+
+ // 색상 결정
+ Color barColor;
+ if (entry.DurationMs >= avgDuration * 2.0)
+ barColor = Color.FromRgb(0xEF, 0x44, 0x44); // 빨강 (병목)
+ else if (entry.DurationMs >= avgDuration * 1.5)
+ barColor = Color.FromRgb(0xF9, 0x73, 0x16); // 주황 (주의)
+ else if (entry.Type == "llm")
+ barColor = Color.FromRgb(0x60, 0xA5, 0xFA); // 파랑 (LLM)
+ else
+ barColor = Color.FromRgb(0x34, 0xD3, 0x99); // 녹색 (도구)
+
+ // 라벨
+ var label = new TextBlock
+ {
+ Text = Truncate(entry.Label, 12),
+ FontSize = 10,
+ FontFamily = ThemeResourceHelper.Consolas,
+ Foreground = new SolidColorBrush(barColor),
+ Width = labelWidth,
+ TextAlignment = TextAlignment.Right,
+ };
+ Canvas.SetLeft(label, 0);
+ Canvas.SetTop(label, y + 2);
+ WaterfallCanvas.Children.Add(label);
+
+ // 바
+ var barStart = (entry.StartMs / (double)maxEndMs) * barAreaWidth;
+ var barWidth = Math.Max((entry.DurationMs / (double)maxEndMs) * barAreaWidth, 3);
+
+ var bar = new Rectangle
+ {
+ Width = barWidth,
+ Height = 14,
+ RadiusX = 3, RadiusY = 3,
+ Fill = new SolidColorBrush(barColor),
+ Opacity = 0.85,
+ ToolTip = $"{entry.Label}: {FormatMs(entry.DurationMs)}",
+ };
+ Canvas.SetLeft(bar, labelWidth + 8 + barStart);
+ Canvas.SetTop(bar, y + 1);
+ WaterfallCanvas.Children.Add(bar);
+
+ // 시간 텍스트
+ var timeText = new TextBlock
+ {
+ Text = FormatMs(entry.DurationMs),
+ FontSize = 9,
+ Foreground = new SolidColorBrush(barColor),
+ FontFamily = ThemeResourceHelper.Consolas,
+ };
+ Canvas.SetLeft(timeText, labelWidth + 8 + barStart + barWidth + 4);
+ Canvas.SetTop(timeText, y + 3);
+ WaterfallCanvas.Children.Add(timeText);
+
+ // 병목 아이콘
+ if (entry.DurationMs >= avgDuration * 2.0)
+ {
+ var icon = new TextBlock
+ {
+ Text = "🔴",
+ FontSize = 9,
+ };
+ Canvas.SetLeft(icon, labelWidth + 8 + barStart + barWidth + 44);
+ Canvas.SetTop(icon, y + 2);
+ WaterfallCanvas.Children.Add(icon);
+ }
+ }
+ }
+
+ /// 도구별 누적 소요시간 수평 바 차트.
+ private void RenderToolTimeChart()
+ {
+ ToolTimePanel.Children.Clear();
+
+ if (_toolTimeAccum.Count == 0)
+ {
+ ToolTimePanel.Children.Add(new TextBlock
+ {
+ Text = "도구 실행 데이터가 없습니다",
+ FontSize = 11,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ Margin = new Thickness(0, 4, 0, 4),
+ });
+ return;
+ }
+
+ // LLM 시간 합산
+ var llmTime = _waterfallEntries
+ .Where(e => e.Type == "llm")
+ .Sum(e => e.DurationMs);
+ var allEntries = _toolTimeAccum
+ .Select(kv => (Name: kv.Key, Ms: kv.Value))
+ .ToList();
+ if (llmTime > 0)
+ allEntries.Add(("LLM 호출", llmTime));
+
+ var sorted = allEntries.OrderByDescending(e => e.Ms).ToList();
+ var maxMs = sorted.Max(e => e.Ms);
+ if (maxMs <= 0) maxMs = 1;
+
+ foreach (var (name, ms) in sorted)
+ {
+ var isBottleneck = ms == sorted[0].Ms;
+ var barColor = name == "LLM 호출"
+ ? Color.FromRgb(0x60, 0xA5, 0xFA)
+ : isBottleneck
+ ? Color.FromRgb(0xEF, 0x44, 0x44)
+ : Color.FromRgb(0x34, 0xD3, 0x99);
+
+ var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(60) });
+
+ // 도구명
+ var nameText = new TextBlock
+ {
+ Text = Truncate(name, 12),
+ FontSize = 11,
+ FontFamily = ThemeResourceHelper.Consolas,
+ Foreground = new SolidColorBrush(barColor),
+ TextAlignment = TextAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 8, 0),
+ };
+ Grid.SetColumn(nameText, 0);
+
+ // 바
+ var barWidth = (ms / (double)maxMs);
+ var barBorder = new Border
+ {
+ Background = new SolidColorBrush(barColor),
+ CornerRadius = new CornerRadius(3),
+ Height = 14,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Width = 0, // 나중에 SizeChanged에서 설정
+ Opacity = 0.8,
+ };
+ var barContainer = new Border { Margin = new Thickness(0, 2, 0, 2) };
+ barContainer.Child = barBorder;
+ barContainer.SizeChanged += (_, _) =>
+ {
+ barBorder.Width = Math.Max(barContainer.ActualWidth * barWidth, 4);
+ };
+ Grid.SetColumn(barContainer, 1);
+
+ // 시간
+ var timeText = new TextBlock
+ {
+ Text = FormatMs(ms),
+ FontSize = 10,
+ Foreground = new SolidColorBrush(barColor),
+ FontFamily = ThemeResourceHelper.Consolas,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(4, 0, 0, 0),
+ };
+ Grid.SetColumn(timeText, 2);
+
+ row.Children.Add(nameText);
+ row.Children.Add(barContainer);
+ row.Children.Add(timeText);
+ ToolTimePanel.Children.Add(row);
+ }
+ }
+
+ /// 반복별 토큰 추세 꺾은선 그래프.
+ private void RenderTokenTrendChart()
+ {
+ TokenTrendCanvas.Children.Clear();
+
+ if (_tokenTrend.Count < 2)
+ {
+ TokenTrendCanvas.Height = 40;
+ TokenTrendCanvas.Children.Add(new TextBlock
+ {
+ Text = _tokenTrend.Count == 0 ? "토큰 데이터가 없습니다" : "2회 이상 반복이 필요합니다",
+ FontSize = 11,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ });
+ Canvas.SetLeft(TokenTrendCanvas.Children[0], 10);
+ Canvas.SetTop(TokenTrendCanvas.Children[0], 10);
+ return;
+ }
+
+ TokenTrendCanvas.Height = 100;
+ var canvasWidth = TokenTrendCanvas.ActualWidth > 0 ? TokenTrendCanvas.ActualWidth : 480;
+ var canvasHeight = 90.0;
+ var marginLeft = 45.0;
+ var marginRight = 10.0;
+ var chartWidth = canvasWidth - marginLeft - marginRight;
+ var sorted = _tokenTrend.OrderBy(t => t.Iteration).ToList();
+ var maxToken = sorted.Max(t => Math.Max(t.Input, t.Output));
+ if (maxToken <= 0) maxToken = 1;
+
+ // Y축 눈금
+ for (int i = 0; i <= 2; i++)
+ {
+ var y = canvasHeight * (1 - i / 2.0);
+ var val = maxToken * i / 2;
+ var gridLine = new Line
+ {
+ X1 = marginLeft, Y1 = y,
+ X2 = canvasWidth - marginRight, Y2 = y,
+ Stroke = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ StrokeThickness = 0.5,
+ Opacity = 0.4,
+ };
+ TokenTrendCanvas.Children.Add(gridLine);
+
+ var yLabel = new TextBlock
+ {
+ Text = val >= 1000 ? $"{val / 1000.0:F0}k" : val.ToString(),
+ FontSize = 9,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ TextAlignment = TextAlignment.Right,
+ Width = marginLeft - 6,
+ };
+ Canvas.SetLeft(yLabel, 0);
+ Canvas.SetTop(yLabel, y - 6);
+ TokenTrendCanvas.Children.Add(yLabel);
+ }
+
+ // 입력 토큰 선 (파랑)
+ DrawPolyline(sorted.Select((t, i) => new Point(
+ marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth,
+ canvasHeight * (1 - t.Input / (double)maxToken)
+ )).ToList(), "#3B82F6");
+
+ // 출력 토큰 선 (녹색)
+ DrawPolyline(sorted.Select((t, i) => new Point(
+ marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth,
+ canvasHeight * (1 - t.Output / (double)maxToken)
+ )).ToList(), "#10B981");
+
+ // X축 라벨
+ foreach (var (iter, _, _) in sorted)
+ {
+ var idx = sorted.FindIndex(t => t.Iteration == iter);
+ var x = marginLeft + (idx / (double)(sorted.Count - 1)) * chartWidth;
+ var xLabel = new TextBlock
+ {
+ Text = $"#{iter}",
+ FontSize = 9,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ };
+ Canvas.SetLeft(xLabel, x - 8);
+ Canvas.SetTop(xLabel, canvasHeight + 2);
+ TokenTrendCanvas.Children.Add(xLabel);
+ }
+
+ // 범례
+ var legend = new StackPanel { Orientation = Orientation.Horizontal };
+ legend.Children.Add(CreateLegendDot("#3B82F6", "입력"));
+ legend.Children.Add(CreateLegendDot("#10B981", "출력"));
+ Canvas.SetRight(legend, 10);
+ Canvas.SetTop(legend, 0);
+ TokenTrendCanvas.Children.Add(legend);
+
+ // 컨텍스트 폭발 경고
+ if (sorted.Count >= 3)
+ {
+ var inputValues = sorted.Select(t => t.Input).ToList();
+ bool increasing = true;
+ for (int i = 1; i < inputValues.Count; i++)
+ {
+ if (inputValues[i] <= inputValues[i - 1]) { increasing = false; break; }
+ }
+ if (increasing && inputValues[^1] > inputValues[0] * 2)
+ {
+ var warning = new TextBlock
+ {
+ Text = "⚠ 컨텍스트 크기 급증",
+ FontSize = 10,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
+ FontWeight = FontWeights.SemiBold,
+ };
+ Canvas.SetLeft(warning, marginLeft + 4);
+ Canvas.SetTop(warning, 0);
+ TokenTrendCanvas.Children.Add(warning);
+ }
+ }
+ }
+
+ private void DrawPolyline(List points, string colorHex)
+ {
+ if (points.Count < 2) return;
+ var color = (Color)ColorConverter.ConvertFromString(colorHex);
+
+ var polyline = new Polyline
+ {
+ Stroke = new SolidColorBrush(color),
+ StrokeThickness = 2,
+ StrokeLineJoin = PenLineJoin.Round,
+ };
+ foreach (var p in points)
+ polyline.Points.Add(p);
+ TokenTrendCanvas.Children.Add(polyline);
+
+ // 점 찍기
+ foreach (var p in points)
+ {
+ var dot = new Ellipse
+ {
+ Width = 6, Height = 6,
+ Fill = new SolidColorBrush(color),
+ };
+ Canvas.SetLeft(dot, p.X - 3);
+ Canvas.SetTop(dot, p.Y - 3);
+ TokenTrendCanvas.Children.Add(dot);
+ }
+ }
+
+ private static StackPanel CreateLegendDot(string colorHex, string text)
+ {
+ var color = (Color)ColorConverter.ConvertFromString(colorHex);
+ var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0) };
+ sp.Children.Add(new Ellipse
+ {
+ Width = 6, Height = 6,
+ Fill = new SolidColorBrush(color),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 3, 0),
+ });
+ sp.Children.Add(new TextBlock
+ {
+ Text = text,
+ FontSize = 9,
+ Foreground = new SolidColorBrush(color),
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ return sp;
+ }
+
+ // ─── 타임라인 노드 생성 ────────────────────────────────────────
+
+ private Border CreateTimelineNode(AgentEvent evt)
+ {
+ var (icon, iconColor, label) = GetEventVisual(evt);
+
+ var node = new Border
+ {
+ Background = Brushes.Transparent,
+ Margin = new Thickness(0, 1, 0, 1),
+ Padding = new Thickness(8, 6, 8, 6),
+ CornerRadius = new CornerRadius(8),
+ Cursor = Cursors.Hand,
+ Tag = evt,
+ };
+
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ // 아이콘 원
+ var iconBorder = new Border
+ {
+ Width = 22, Height = 22,
+ CornerRadius = new CornerRadius(11),
+ Background = new SolidColorBrush(iconColor),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 2, 0, 0),
+ };
+ iconBorder.Child = new TextBlock
+ {
+ Text = icon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 10,
+ Foreground = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ Grid.SetColumn(iconBorder, 0);
+ grid.Children.Add(iconBorder);
+
+ // 타임라인 세로 선
+ var line = new Rectangle
+ {
+ Width = 2,
+ Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ Opacity = 0.4,
+ };
+ Grid.SetColumn(line, 1);
+ grid.Children.Add(line);
+
+ // 내용
+ var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) };
+
+ var headerPanel = new StackPanel { Orientation = Orientation.Horizontal };
+ headerPanel.Children.Add(new TextBlock
+ {
+ Text = label,
+ FontSize = 12,
+ FontWeight = FontWeights.SemiBold,
+ Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+
+ if (evt.ElapsedMs > 0)
+ {
+ headerPanel.Children.Add(new TextBlock
+ {
+ Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms",
+ FontSize = 10,
+ Foreground = evt.ElapsedMs > 3000
+ ? Brushes.OrangeRed
+ : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ }
+
+ headerPanel.Children.Add(new TextBlock
+ {
+ Text = $" {evt.Timestamp:HH:mm:ss}",
+ FontSize = 9,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ VerticalAlignment = VerticalAlignment.Center,
+ Opacity = 0.6,
+ });
+ content.Children.Add(headerPanel);
+
+ if (!string.IsNullOrEmpty(evt.Summary))
+ {
+ content.Children.Add(new TextBlock
+ {
+ Text = Truncate(evt.Summary, 120),
+ FontSize = 11,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 2, 0, 0),
+ LineHeight = 16,
+ });
+ }
+
+ if (evt.InputTokens > 0 || evt.OutputTokens > 0)
+ {
+ var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) };
+ tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6"));
+ tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981"));
+ content.Children.Add(tokenPanel);
+ }
+
+ Grid.SetColumn(content, 2);
+ grid.Children.Add(content);
+ node.Child = grid;
+
+ var hoverBrush = TryFindResource("ItemHoverBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+ node.MouseEnter += (_, _) => node.Background = hoverBrush;
+ node.MouseLeave += (_, _) => node.Background = Brushes.Transparent;
+ node.MouseLeftButtonUp += (_, _) => ShowDetail(evt);
+
+ return node;
+ }
+
+ private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt)
+ {
+ return evt.Type switch
+ {
+ AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"),
+ AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"),
+ AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"),
+ AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"),
+ AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName),
+ AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"),
+ AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"),
+ AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"),
+ AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"),
+ AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"),
+ _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"),
+ };
+ }
+
+ private static Border CreateBadge(string text, string colorHex)
+ {
+ var color = (Color)ColorConverter.ConvertFromString(colorHex);
+ return new Border
+ {
+ Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(5, 1, 5, 1),
+ Margin = new Thickness(0, 0, 4, 0),
+ Child = new TextBlock
+ {
+ Text = text,
+ FontSize = 9,
+ Foreground = new SolidColorBrush(color),
+ FontWeight = FontWeights.SemiBold,
+ },
+ };
+ }
+
+ // ─── 상세 패널 ─────────────────────────────────────────────
+
+ private void ShowDetail(AgentEvent evt)
+ {
+ DetailPanel.Visibility = Visibility.Visible;
+ DetailPanel.Tag = evt;
+ var (_, color, label) = GetEventVisual(evt);
+
+ DetailTitle.Text = label;
+ DetailBadge.Text = evt.Success ? "성공" : "실패";
+ DetailBadge.Background = new SolidColorBrush(evt.Success
+ ? Color.FromRgb(0x34, 0xD3, 0x99)
+ : Color.FromRgb(0xF8, 0x71, 0x71));
+
+ var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}";
+ if (evt.ElapsedMs > 0)
+ meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}";
+ if (evt.InputTokens > 0 || evt.OutputTokens > 0)
+ meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}";
+ if (evt.Iteration > 0)
+ meta += $" | 반복 #{evt.Iteration}";
+ DetailMeta.Text = meta;
+
+ var contentText = evt.Summary ?? "";
+ if (!string.IsNullOrEmpty(evt.ToolInput))
+ contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}";
+ if (!string.IsNullOrEmpty(evt.FilePath))
+ contentText += $"\n파일: {evt.FilePath}";
+ DetailContent.Text = contentText;
+ }
+
+ // ─── 요약 카드 업데이트 ──────────────────────────────────────
+
+ private void UpdateSummaryCards()
+ {
+ var elapsed = (DateTime.Now - _startTime).TotalSeconds;
+ CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s";
+ CardIterations.Text = _maxIteration.ToString();
+ var totalTokens = _totalInputTokens + _totalOutputTokens;
+ CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString();
+ CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString();
+ CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString();
+ CardToolCalls.Text = _totalToolCalls.ToString();
+ }
+
+ // ─── 유틸리티 ────────────────────────────────────────────────
+
+ private static string FormatMs(long ms)
+ => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms";
+
+ // ─── 윈도우 이벤트 ────────────────────────────────────────────
+
+ private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지)
+ var src = e.OriginalSource as DependencyObject;
+ while (src != null && src != sender)
+ {
+ if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear")
+ return;
+ src = VisualTreeHelper.GetParent(src);
+ }
+ if (e.ClickCount == 1) DragMove();
+ }
+
+ private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide();
+ private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized;
+ private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset();
+
+ private void TitleBtn_MouseEnter(object sender, MouseEventArgs e)
+ {
+ if (sender is Border b)
+ b.Background = TryFindResource("ItemHoverBackground") as Brush
+ ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+ }
+
+ private void TitleBtn_MouseLeave(object sender, MouseEventArgs e)
+ {
+ if (sender is Border b)
+ b.Background = Brushes.Transparent;
+ }
+
+ private static string Truncate(string text, int maxLen)
+ => text.Length <= maxLen ? text : text[..maxLen] + "…";
+
+ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
+ {
+ if (msg == WM_NCHITTEST)
+ {
+ var pt = PointFromScreen(new Point(
+ (short)(lParam.ToInt32() & 0xFFFF),
+ (short)((lParam.ToInt32() >> 16) & 0xFFFF)));
+ const double grip = 8;
+ var w = ActualWidth;
+ var h = ActualHeight;
+
+ if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; }
+ if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; }
+ if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; }
+ if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; }
+ if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; }
+ if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; }
+ if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; }
+ if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; }
+ }
+ return IntPtr.Zero;
+ }
+}
diff --git a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs
index 0aab725..ced71fa 100644
--- a/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs
+++ b/src/AxCopilot/Views/WorkflowAnalyzerWindow.xaml.cs
@@ -271,659 +271,4 @@ public partial class WorkflowAnalyzerWindow : Window
}
}
- // ─── 병목 분석 차트 렌더링 ─────────────────────────────────────
-
- private void ClearBottleneckCharts()
- {
- WaterfallCanvas.Children.Clear();
- ToolTimePanel.Children.Clear();
- TokenTrendCanvas.Children.Clear();
- }
-
- private void RenderBottleneckCharts()
- {
- ClearBottleneckCharts();
- RenderWaterfallChart();
- RenderToolTimeChart();
- RenderTokenTrendChart();
- }
-
- /// 워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색.
- private void RenderWaterfallChart()
- {
- if (_waterfallEntries.Count == 0)
- {
- WaterfallCanvas.Height = 40;
- var emptyMsg = new TextBlock
- {
- Text = "실행 데이터가 없습니다",
- FontSize = 11,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- };
- Canvas.SetLeft(emptyMsg, 10);
- Canvas.SetTop(emptyMsg, 10);
- WaterfallCanvas.Children.Add(emptyMsg);
- return;
- }
-
- // 병목 판정: 평균 × 2.0 이상 = 빨강, × 1.5 이상 = 주황
- var avgDuration = _waterfallEntries.Average(e => e.DurationMs);
- var maxEndMs = _waterfallEntries.Max(e => e.StartMs + e.DurationMs);
- if (maxEndMs <= 0) maxEndMs = 1;
-
- var rowHeight = 22;
- var labelWidth = 90.0;
- var chartHeight = _waterfallEntries.Count * rowHeight + 10;
- WaterfallCanvas.Height = Math.Max(chartHeight, 60);
-
- var canvasWidth = WaterfallCanvas.ActualWidth > 0 ? WaterfallCanvas.ActualWidth : 480;
- var barAreaWidth = canvasWidth - labelWidth - 60; // 우측에 시간 텍스트 공간
-
- for (int i = 0; i < _waterfallEntries.Count; i++)
- {
- var entry = _waterfallEntries[i];
- var y = i * rowHeight + 4;
-
- // 색상 결정
- Color barColor;
- if (entry.DurationMs >= avgDuration * 2.0)
- barColor = Color.FromRgb(0xEF, 0x44, 0x44); // 빨강 (병목)
- else if (entry.DurationMs >= avgDuration * 1.5)
- barColor = Color.FromRgb(0xF9, 0x73, 0x16); // 주황 (주의)
- else if (entry.Type == "llm")
- barColor = Color.FromRgb(0x60, 0xA5, 0xFA); // 파랑 (LLM)
- else
- barColor = Color.FromRgb(0x34, 0xD3, 0x99); // 녹색 (도구)
-
- // 라벨
- var label = new TextBlock
- {
- Text = Truncate(entry.Label, 12),
- FontSize = 10,
- FontFamily = ThemeResourceHelper.Consolas,
- Foreground = new SolidColorBrush(barColor),
- Width = labelWidth,
- TextAlignment = TextAlignment.Right,
- };
- Canvas.SetLeft(label, 0);
- Canvas.SetTop(label, y + 2);
- WaterfallCanvas.Children.Add(label);
-
- // 바
- var barStart = (entry.StartMs / (double)maxEndMs) * barAreaWidth;
- var barWidth = Math.Max((entry.DurationMs / (double)maxEndMs) * barAreaWidth, 3);
-
- var bar = new Rectangle
- {
- Width = barWidth,
- Height = 14,
- RadiusX = 3, RadiusY = 3,
- Fill = new SolidColorBrush(barColor),
- Opacity = 0.85,
- ToolTip = $"{entry.Label}: {FormatMs(entry.DurationMs)}",
- };
- Canvas.SetLeft(bar, labelWidth + 8 + barStart);
- Canvas.SetTop(bar, y + 1);
- WaterfallCanvas.Children.Add(bar);
-
- // 시간 텍스트
- var timeText = new TextBlock
- {
- Text = FormatMs(entry.DurationMs),
- FontSize = 9,
- Foreground = new SolidColorBrush(barColor),
- FontFamily = ThemeResourceHelper.Consolas,
- };
- Canvas.SetLeft(timeText, labelWidth + 8 + barStart + barWidth + 4);
- Canvas.SetTop(timeText, y + 3);
- WaterfallCanvas.Children.Add(timeText);
-
- // 병목 아이콘
- if (entry.DurationMs >= avgDuration * 2.0)
- {
- var icon = new TextBlock
- {
- Text = "🔴",
- FontSize = 9,
- };
- Canvas.SetLeft(icon, labelWidth + 8 + barStart + barWidth + 44);
- Canvas.SetTop(icon, y + 2);
- WaterfallCanvas.Children.Add(icon);
- }
- }
- }
-
- /// 도구별 누적 소요시간 수평 바 차트.
- private void RenderToolTimeChart()
- {
- ToolTimePanel.Children.Clear();
-
- if (_toolTimeAccum.Count == 0)
- {
- ToolTimePanel.Children.Add(new TextBlock
- {
- Text = "도구 실행 데이터가 없습니다",
- FontSize = 11,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- Margin = new Thickness(0, 4, 0, 4),
- });
- return;
- }
-
- // LLM 시간 합산
- var llmTime = _waterfallEntries
- .Where(e => e.Type == "llm")
- .Sum(e => e.DurationMs);
- var allEntries = _toolTimeAccum
- .Select(kv => (Name: kv.Key, Ms: kv.Value))
- .ToList();
- if (llmTime > 0)
- allEntries.Add(("LLM 호출", llmTime));
-
- var sorted = allEntries.OrderByDescending(e => e.Ms).ToList();
- var maxMs = sorted.Max(e => e.Ms);
- if (maxMs <= 0) maxMs = 1;
-
- foreach (var (name, ms) in sorted)
- {
- var isBottleneck = ms == sorted[0].Ms;
- var barColor = name == "LLM 호출"
- ? Color.FromRgb(0x60, 0xA5, 0xFA)
- : isBottleneck
- ? Color.FromRgb(0xEF, 0x44, 0x44)
- : Color.FromRgb(0x34, 0xD3, 0x99);
-
- var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
- row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(60) });
-
- // 도구명
- var nameText = new TextBlock
- {
- Text = Truncate(name, 12),
- FontSize = 11,
- FontFamily = ThemeResourceHelper.Consolas,
- Foreground = new SolidColorBrush(barColor),
- TextAlignment = TextAlignment.Right,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 8, 0),
- };
- Grid.SetColumn(nameText, 0);
-
- // 바
- var barWidth = (ms / (double)maxMs);
- var barBorder = new Border
- {
- Background = new SolidColorBrush(barColor),
- CornerRadius = new CornerRadius(3),
- Height = 14,
- HorizontalAlignment = HorizontalAlignment.Left,
- Width = 0, // 나중에 SizeChanged에서 설정
- Opacity = 0.8,
- };
- var barContainer = new Border { Margin = new Thickness(0, 2, 0, 2) };
- barContainer.Child = barBorder;
- barContainer.SizeChanged += (_, _) =>
- {
- barBorder.Width = Math.Max(barContainer.ActualWidth * barWidth, 4);
- };
- Grid.SetColumn(barContainer, 1);
-
- // 시간
- var timeText = new TextBlock
- {
- Text = FormatMs(ms),
- FontSize = 10,
- Foreground = new SolidColorBrush(barColor),
- FontFamily = ThemeResourceHelper.Consolas,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(4, 0, 0, 0),
- };
- Grid.SetColumn(timeText, 2);
-
- row.Children.Add(nameText);
- row.Children.Add(barContainer);
- row.Children.Add(timeText);
- ToolTimePanel.Children.Add(row);
- }
- }
-
- /// 반복별 토큰 추세 꺾은선 그래프.
- private void RenderTokenTrendChart()
- {
- TokenTrendCanvas.Children.Clear();
-
- if (_tokenTrend.Count < 2)
- {
- TokenTrendCanvas.Height = 40;
- TokenTrendCanvas.Children.Add(new TextBlock
- {
- Text = _tokenTrend.Count == 0 ? "토큰 데이터가 없습니다" : "2회 이상 반복이 필요합니다",
- FontSize = 11,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- });
- Canvas.SetLeft(TokenTrendCanvas.Children[0], 10);
- Canvas.SetTop(TokenTrendCanvas.Children[0], 10);
- return;
- }
-
- TokenTrendCanvas.Height = 100;
- var canvasWidth = TokenTrendCanvas.ActualWidth > 0 ? TokenTrendCanvas.ActualWidth : 480;
- var canvasHeight = 90.0;
- var marginLeft = 45.0;
- var marginRight = 10.0;
- var chartWidth = canvasWidth - marginLeft - marginRight;
- var sorted = _tokenTrend.OrderBy(t => t.Iteration).ToList();
- var maxToken = sorted.Max(t => Math.Max(t.Input, t.Output));
- if (maxToken <= 0) maxToken = 1;
-
- // Y축 눈금
- for (int i = 0; i <= 2; i++)
- {
- var y = canvasHeight * (1 - i / 2.0);
- var val = maxToken * i / 2;
- var gridLine = new Line
- {
- X1 = marginLeft, Y1 = y,
- X2 = canvasWidth - marginRight, Y2 = y,
- Stroke = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
- StrokeThickness = 0.5,
- Opacity = 0.4,
- };
- TokenTrendCanvas.Children.Add(gridLine);
-
- var yLabel = new TextBlock
- {
- Text = val >= 1000 ? $"{val / 1000.0:F0}k" : val.ToString(),
- FontSize = 9,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- TextAlignment = TextAlignment.Right,
- Width = marginLeft - 6,
- };
- Canvas.SetLeft(yLabel, 0);
- Canvas.SetTop(yLabel, y - 6);
- TokenTrendCanvas.Children.Add(yLabel);
- }
-
- // 입력 토큰 선 (파랑)
- DrawPolyline(sorted.Select((t, i) => new Point(
- marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth,
- canvasHeight * (1 - t.Input / (double)maxToken)
- )).ToList(), "#3B82F6");
-
- // 출력 토큰 선 (녹색)
- DrawPolyline(sorted.Select((t, i) => new Point(
- marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth,
- canvasHeight * (1 - t.Output / (double)maxToken)
- )).ToList(), "#10B981");
-
- // X축 라벨
- foreach (var (iter, _, _) in sorted)
- {
- var idx = sorted.FindIndex(t => t.Iteration == iter);
- var x = marginLeft + (idx / (double)(sorted.Count - 1)) * chartWidth;
- var xLabel = new TextBlock
- {
- Text = $"#{iter}",
- FontSize = 9,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- };
- Canvas.SetLeft(xLabel, x - 8);
- Canvas.SetTop(xLabel, canvasHeight + 2);
- TokenTrendCanvas.Children.Add(xLabel);
- }
-
- // 범례
- var legend = new StackPanel { Orientation = Orientation.Horizontal };
- legend.Children.Add(CreateLegendDot("#3B82F6", "입력"));
- legend.Children.Add(CreateLegendDot("#10B981", "출력"));
- Canvas.SetRight(legend, 10);
- Canvas.SetTop(legend, 0);
- TokenTrendCanvas.Children.Add(legend);
-
- // 컨텍스트 폭발 경고
- if (sorted.Count >= 3)
- {
- var inputValues = sorted.Select(t => t.Input).ToList();
- bool increasing = true;
- for (int i = 1; i < inputValues.Count; i++)
- {
- if (inputValues[i] <= inputValues[i - 1]) { increasing = false; break; }
- }
- if (increasing && inputValues[^1] > inputValues[0] * 2)
- {
- var warning = new TextBlock
- {
- Text = "⚠ 컨텍스트 크기 급증",
- FontSize = 10,
- Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
- FontWeight = FontWeights.SemiBold,
- };
- Canvas.SetLeft(warning, marginLeft + 4);
- Canvas.SetTop(warning, 0);
- TokenTrendCanvas.Children.Add(warning);
- }
- }
- }
-
- private void DrawPolyline(List points, string colorHex)
- {
- if (points.Count < 2) return;
- var color = (Color)ColorConverter.ConvertFromString(colorHex);
-
- var polyline = new Polyline
- {
- Stroke = new SolidColorBrush(color),
- StrokeThickness = 2,
- StrokeLineJoin = PenLineJoin.Round,
- };
- foreach (var p in points)
- polyline.Points.Add(p);
- TokenTrendCanvas.Children.Add(polyline);
-
- // 점 찍기
- foreach (var p in points)
- {
- var dot = new Ellipse
- {
- Width = 6, Height = 6,
- Fill = new SolidColorBrush(color),
- };
- Canvas.SetLeft(dot, p.X - 3);
- Canvas.SetTop(dot, p.Y - 3);
- TokenTrendCanvas.Children.Add(dot);
- }
- }
-
- private static StackPanel CreateLegendDot(string colorHex, string text)
- {
- var color = (Color)ColorConverter.ConvertFromString(colorHex);
- var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0) };
- sp.Children.Add(new Ellipse
- {
- Width = 6, Height = 6,
- Fill = new SolidColorBrush(color),
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 3, 0),
- });
- sp.Children.Add(new TextBlock
- {
- Text = text,
- FontSize = 9,
- Foreground = new SolidColorBrush(color),
- VerticalAlignment = VerticalAlignment.Center,
- });
- return sp;
- }
-
- // ─── 타임라인 노드 생성 ────────────────────────────────────────
-
- private Border CreateTimelineNode(AgentEvent evt)
- {
- var (icon, iconColor, label) = GetEventVisual(evt);
-
- var node = new Border
- {
- Background = Brushes.Transparent,
- Margin = new Thickness(0, 1, 0, 1),
- Padding = new Thickness(8, 6, 8, 6),
- CornerRadius = new CornerRadius(8),
- Cursor = Cursors.Hand,
- Tag = evt,
- };
-
- var grid = new Grid();
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) });
- grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
-
- // 아이콘 원
- var iconBorder = new Border
- {
- Width = 22, Height = 22,
- CornerRadius = new CornerRadius(11),
- Background = new SolidColorBrush(iconColor),
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Top,
- Margin = new Thickness(0, 2, 0, 0),
- };
- iconBorder.Child = new TextBlock
- {
- Text = icon,
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 10,
- Foreground = Brushes.White,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- };
- Grid.SetColumn(iconBorder, 0);
- grid.Children.Add(iconBorder);
-
- // 타임라인 세로 선
- var line = new Rectangle
- {
- Width = 2,
- Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Stretch,
- Opacity = 0.4,
- };
- Grid.SetColumn(line, 1);
- grid.Children.Add(line);
-
- // 내용
- var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) };
-
- var headerPanel = new StackPanel { Orientation = Orientation.Horizontal };
- headerPanel.Children.Add(new TextBlock
- {
- Text = label,
- FontSize = 12,
- FontWeight = FontWeights.SemiBold,
- Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
- VerticalAlignment = VerticalAlignment.Center,
- });
-
- if (evt.ElapsedMs > 0)
- {
- headerPanel.Children.Add(new TextBlock
- {
- Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms",
- FontSize = 10,
- Foreground = evt.ElapsedMs > 3000
- ? Brushes.OrangeRed
- : TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- VerticalAlignment = VerticalAlignment.Center,
- });
- }
-
- headerPanel.Children.Add(new TextBlock
- {
- Text = $" {evt.Timestamp:HH:mm:ss}",
- FontSize = 9,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- VerticalAlignment = VerticalAlignment.Center,
- Opacity = 0.6,
- });
- content.Children.Add(headerPanel);
-
- if (!string.IsNullOrEmpty(evt.Summary))
- {
- content.Children.Add(new TextBlock
- {
- Text = Truncate(evt.Summary, 120),
- FontSize = 11,
- Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 2, 0, 0),
- LineHeight = 16,
- });
- }
-
- if (evt.InputTokens > 0 || evt.OutputTokens > 0)
- {
- var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) };
- tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6"));
- tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981"));
- content.Children.Add(tokenPanel);
- }
-
- Grid.SetColumn(content, 2);
- grid.Children.Add(content);
- node.Child = grid;
-
- var hoverBrush = TryFindResource("ItemHoverBackground") as Brush
- ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
- node.MouseEnter += (_, _) => node.Background = hoverBrush;
- node.MouseLeave += (_, _) => node.Background = Brushes.Transparent;
- node.MouseLeftButtonUp += (_, _) => ShowDetail(evt);
-
- return node;
- }
-
- private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt)
- {
- return evt.Type switch
- {
- AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"),
- AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"),
- AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"),
- AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"),
- AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName),
- AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"),
- AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"),
- AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"),
- AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"),
- AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"),
- _ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"),
- };
- }
-
- private static Border CreateBadge(string text, string colorHex)
- {
- var color = (Color)ColorConverter.ConvertFromString(colorHex);
- return new Border
- {
- Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)),
- CornerRadius = new CornerRadius(4),
- Padding = new Thickness(5, 1, 5, 1),
- Margin = new Thickness(0, 0, 4, 0),
- Child = new TextBlock
- {
- Text = text,
- FontSize = 9,
- Foreground = new SolidColorBrush(color),
- FontWeight = FontWeights.SemiBold,
- },
- };
- }
-
- // ─── 상세 패널 ─────────────────────────────────────────────
-
- private void ShowDetail(AgentEvent evt)
- {
- DetailPanel.Visibility = Visibility.Visible;
- DetailPanel.Tag = evt;
- var (_, color, label) = GetEventVisual(evt);
-
- DetailTitle.Text = label;
- DetailBadge.Text = evt.Success ? "성공" : "실패";
- DetailBadge.Background = new SolidColorBrush(evt.Success
- ? Color.FromRgb(0x34, 0xD3, 0x99)
- : Color.FromRgb(0xF8, 0x71, 0x71));
-
- var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}";
- if (evt.ElapsedMs > 0)
- meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}";
- if (evt.InputTokens > 0 || evt.OutputTokens > 0)
- meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}";
- if (evt.Iteration > 0)
- meta += $" | 반복 #{evt.Iteration}";
- DetailMeta.Text = meta;
-
- var contentText = evt.Summary ?? "";
- if (!string.IsNullOrEmpty(evt.ToolInput))
- contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}";
- if (!string.IsNullOrEmpty(evt.FilePath))
- contentText += $"\n파일: {evt.FilePath}";
- DetailContent.Text = contentText;
- }
-
- // ─── 요약 카드 업데이트 ──────────────────────────────────────
-
- private void UpdateSummaryCards()
- {
- var elapsed = (DateTime.Now - _startTime).TotalSeconds;
- CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s";
- CardIterations.Text = _maxIteration.ToString();
- var totalTokens = _totalInputTokens + _totalOutputTokens;
- CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString();
- CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString();
- CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString();
- CardToolCalls.Text = _totalToolCalls.ToString();
- }
-
- // ─── 유틸리티 ────────────────────────────────────────────────
-
- private static string FormatMs(long ms)
- => ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms";
-
- // ─── 윈도우 이벤트 ────────────────────────────────────────────
-
- private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
- {
- // 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지)
- var src = e.OriginalSource as DependencyObject;
- while (src != null && src != sender)
- {
- if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear")
- return;
- src = VisualTreeHelper.GetParent(src);
- }
- if (e.ClickCount == 1) DragMove();
- }
-
- private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide();
- private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized;
- private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset();
-
- private void TitleBtn_MouseEnter(object sender, MouseEventArgs e)
- {
- if (sender is Border b)
- b.Background = TryFindResource("ItemHoverBackground") as Brush
- ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
- }
-
- private void TitleBtn_MouseLeave(object sender, MouseEventArgs e)
- {
- if (sender is Border b)
- b.Background = Brushes.Transparent;
- }
-
- private static string Truncate(string text, int maxLen)
- => text.Length <= maxLen ? text : text[..maxLen] + "…";
-
- private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
- {
- if (msg == WM_NCHITTEST)
- {
- var pt = PointFromScreen(new Point(
- (short)(lParam.ToInt32() & 0xFFFF),
- (short)((lParam.ToInt32() >> 16) & 0xFFFF)));
- const double grip = 8;
- var w = ActualWidth;
- var h = ActualHeight;
-
- if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; }
- if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; }
- if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; }
- if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; }
- if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; }
- if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; }
- if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; }
- if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; }
- }
- return IntPtr.Zero;
- }
}