using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Threading; using Microsoft.Win32; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { // ─── 프로젝트 문맥 파일 (AGENTS.md) ────────────────────────────────── /// /// 작업 폴더에 AGENTS.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다. /// 프로젝트 로컬 컨텍스트 규약 파일(AGENTS.md) 형식을 사용합니다. /// private static string LoadProjectContext(string workFolder) { if (string.IsNullOrEmpty(workFolder)) return ""; // AGENTS.md 탐색 (작업 폴더 → 상위 폴더 순, 레거시 AX.md 폴백) var searchDir = workFolder; for (int i = 0; i < 3; i++) // 최대 3단계 상위까지 { if (string.IsNullOrEmpty(searchDir)) break; var agentsPath = System.IO.Path.Combine(searchDir, "AGENTS.md"); var legacyPath = System.IO.Path.Combine(searchDir, "AX.md"); var filePath = System.IO.File.Exists(agentsPath) ? agentsPath : legacyPath; if (System.IO.File.Exists(filePath)) { try { var content = System.IO.File.ReadAllText(filePath); if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)"; var sourceName = System.IO.Path.GetFileName(filePath); return $"\n## Project Context (from {sourceName})\n{content}\n"; } catch { } } searchDir = System.IO.Directory.GetParent(searchDir)?.FullName; } return ""; } // ─── 부드러운 스크롤 애니메이션 (재사용 타이머) ────────────────────── private DispatcherTimer? _smoothScrollTimer; private double _smoothScrollStartOffset; private double _smoothScrollDiff; private DateTime _smoothScrollStartTime; private void SmoothScrollTimer_Tick(object? sender, EventArgs e) { var elapsed = (DateTime.UtcNow - _smoothScrollStartTime).TotalMilliseconds; var progress = Math.Min(elapsed / 200.0, 1.0); var eased = 1.0 - Math.Pow(1.0 - progress, 3); ScrollTranscriptToVerticalOffset(_smoothScrollStartOffset + _smoothScrollDiff * eased); if (progress >= 1.0) _smoothScrollTimer?.Stop(); } // ─── 무지개 글로우 애니메이션 ───────────────────────────────────────── private DispatcherTimer? _rainbowTimer; private DateTime _rainbowStartTime; private bool TryGetStreamingElapsed(out TimeSpan elapsed) { elapsed = TimeSpan.Zero; if (_streamStartTime.Year < 2000) return false; var now = DateTime.UtcNow; if (_streamStartTime > now.AddSeconds(1)) return false; elapsed = now - _streamStartTime; if (elapsed < TimeSpan.Zero || elapsed > TimeSpan.FromHours(6)) { elapsed = TimeSpan.Zero; return false; } return true; } private long GetStreamingElapsedMsOrZero() => TryGetStreamingElapsed(out var elapsed) ? Math.Max(0L, (long)elapsed.TotalMilliseconds) : 0L; /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). private void PlayRainbowGlow() { if (!_settings.Settings.Llm.EnableChatRainbowGlow) return; if (_rainbowTimer != null) return; // 이미 실행 중이면 opacity 리셋 없이 그냥 유지 _rainbowStartTime = DateTime.UtcNow; InputGlowBorder.Visibility = Visibility.Visible; InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 }; InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180))); _rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _rainbowTimer.Tick += (_, _) => { var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds; var shift = (elapsed / 2000.0) % 1.0; var brush = InputGlowBorder.BorderBrush as LinearGradientBrush; if (brush == null) return; var angle = shift * Math.PI * 2; brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle)); brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle)); }; _rainbowTimer.Start(); } /// 레인보우 글로우 효과를 페이드아웃하며 중지합니다. private void StopRainbowGlow() { _rainbowTimer?.Stop(); _rainbowTimer = null; if (InputGlowBorder.Opacity > 0 || InputGlowBorder.Visibility == Visibility.Visible) { var fadeOut = new System.Windows.Media.Animation.DoubleAnimation( InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600)); fadeOut.Completed += (_, _) => { InputGlowBorder.Opacity = 0; InputGlowBorder.Visibility = Visibility.Collapsed; }; InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); } else { InputGlowBorder.Visibility = Visibility.Collapsed; } } // ─── 토스트 알림 ────────────────────────────────────────────────────── private DispatcherTimer? _toastHideTimer; /// ToastBorder를 즉시 페이드아웃하고 숨깁니다. private void HideToast() { _toastHideTimer?.Stop(); _toastHideTimer = null; _tipDismissTimer?.Stop(); _tipDismissTimer = null; if (ToastBorder?.Visibility != Visibility.Visible) return; var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(200)); fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); } private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000) { // 두 타이머 모두 중지 (ShowToast/ShowTip이 같은 ToastBorder를 공유) _toastHideTimer?.Stop(); _tipDismissTimer?.Stop(); ToastText.Text = message; ToastIcon.Text = icon; ToastBorder.Visibility = Visibility.Visible; // 페이드인 ToastBorder.BeginAnimation(UIElement.OpacityProperty, new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200))); // 자동 숨기기 — 타이머 인스턴스를 로컬 변수로 캡처해 필드 재할당 간섭 방지 var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) }; _toastHideTimer = timer; timer.Tick += (_, _) => { if (_toastHideTimer != timer) return; // 다른 ShowToast가 교체한 경우 무시 timer.Stop(); _toastHideTimer = null; var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300)); fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed; ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); }; timer.Start(); } /// 선택된 디자인 무드 키 (HtmlSkill에서 사용). private string _selectedMood = null!; // Loaded 이벤트에서 초기화 private string _selectedLanguage = "auto"; // Code 탭 개발 언어 private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화 /// 하단 바를 구성합니다 (Cowork 작업 제어 중심). private void BuildBottomBar() { MoodIconPanel.Children.Clear(); if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; } /// Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심. private void BuildCodeBottomBar() { MoodIconPanel.Children.Clear(); if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; } private Border CreateWorkspaceFolderBarButton() { var currentFolder = GetCurrentWorkFolder(); var label = string.IsNullOrWhiteSpace(currentFolder) ? "워크스페이스" : TruncateForStatus(Path.GetFileName(currentFolder.TrimEnd('\\', '/')), 18); var tooltip = string.IsNullOrWhiteSpace(currentFolder) ? "워크스페이스 선택" : $"워크스페이스 선택\n현재: {currentFolder}"; return CreateFolderBarButton("\uE8B7", label, tooltip, "#4B5EFC"); } private string GetWorktreeModeLabel() { var folder = GetCurrentWorkFolder(); if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return "로컬"; var root = WorktreeStateStore.ResolveRoot(folder); var active = WorktreeStateStore.Load(root).Active; return string.Equals(Path.GetFullPath(active), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase) ? "로컬" : "워크트리"; } private List GetAvailableWorkspaceVariants(string root, string? active) { var variants = new List(); if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) return variants; try { var parent = Directory.GetParent(root)?.FullName ?? root; var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-wt-*")); variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-copy-*")); } catch { // ignore discovery failures } if (!string.IsNullOrWhiteSpace(active) && Directory.Exists(active)) variants.Add(active); return variants .Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) .Where(path => !string.Equals(Path.GetFullPath(path), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderByDescending(path => string.Equals(Path.GetFullPath(path), Path.GetFullPath(active ?? ""), StringComparison.OrdinalIgnoreCase)) .ThenByDescending(path => Directory.GetLastWriteTime(path)) .Take(8) .ToList(); } private void SwitchToWorkspace(string targetPath, string rootPath) { if (string.IsNullOrWhiteSpace(targetPath) || !Directory.Exists(targetPath)) return; if (!string.IsNullOrWhiteSpace(rootPath)) { var state = WorktreeStateStore.Load(rootPath); state.Active = targetPath; WorktreeStateStore.Save(rootPath, state); } SetWorkFolder(targetPath); ShowToast(string.Equals(targetPath, rootPath, StringComparison.OrdinalIgnoreCase) ? "로컬 워크스페이스로 전환했습니다." : "워크트리로 전환했습니다."); } private async Task CreateCurrentBranchWorktreeAsync() { var currentFolder = GetCurrentWorkFolder(); if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder)) return; var root = WorktreeStateStore.ResolveRoot(currentFolder); var gitRoot = ResolveGitRoot(root); if (!string.IsNullOrWhiteSpace(gitRoot)) { await CreateGitWorktreeAsync(gitRoot); return; } var copied = CreateWorkspaceCopy(root); SwitchToWorkspace(copied, root); } private async Task CreateGitWorktreeAsync(string gitRoot) { var gitPath = FindGitExecutablePath(); if (string.IsNullOrWhiteSpace(gitPath)) return; var branchResult = await RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, CancellationToken.None); var branchName = branchResult.ExitCode == 0 ? branchResult.StdOut.Trim() : "worktree"; if (string.IsNullOrWhiteSpace(branchName)) branchName = "worktree"; var safeBranch = string.Concat(branchName.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-'); if (string.IsNullOrWhiteSpace(safeBranch)) safeBranch = "worktree"; var parent = Directory.GetParent(gitRoot)?.FullName ?? gitRoot; var repoName = Path.GetFileName(gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); var suffix = DateTime.Now.ToString("MMddHHmm"); var worktreePath = Path.Combine(parent, $"{repoName}-wt-{safeBranch}-{suffix}"); var worktreeBranch = $"ax/{safeBranch}-{suffix}"; var addResult = await RunGitAsync(gitPath, gitRoot, new[] { "worktree", "add", "-b", worktreeBranch, worktreePath, branchName }, CancellationToken.None); if (addResult.ExitCode != 0) { CustomMessageBox.Show($"워크트리 생성에 실패했습니다.\n{addResult.StdErr.Trim()}", "워크트리", MessageBoxButton.OK, MessageBoxImage.Warning); return; } SwitchToWorkspace(worktreePath, gitRoot); await RefreshGitBranchStatusAsync(); } private string CreateWorkspaceCopy(string root) { var parent = Directory.GetParent(root)?.FullName ?? root; var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); var copyPath = Path.Combine(parent, $"{repoName}-copy-{DateTime.Now:MMddHHmm}"); CopyDirectoryRecursive(root, copyPath, skipGitMetadata: true); return copyPath; } private static void CopyDirectoryRecursive(string source, string destination, bool skipGitMetadata) { Directory.CreateDirectory(destination); foreach (var file in Directory.GetFiles(source)) { var name = Path.GetFileName(file); if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase)) continue; File.Copy(file, Path.Combine(destination, name), overwrite: true); } foreach (var directory in Directory.GetDirectories(source)) { var name = Path.GetFileName(directory); if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase)) continue; CopyDirectoryRecursive(directory, Path.Combine(destination, name), skipGitMetadata); } } /// 하단 바에 실행 이력 상세도 선택 버튼을 추가합니다. private void AppendLogLevelButton() { // 구분선 MoodIconPanel.Children.Add(new Border { Width = 1, Height = 18, Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray, Margin = new Thickness(4, 0, 4, 0), VerticalAlignment = VerticalAlignment.Center, }); var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "detailed"; var levelLabel = currentLevel switch { "debug" => "디버그", "detailed" => "상세", "hidden" => "숨김", _ => "간략", }; var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669"); logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); }; try { RegisterName("BtnLogLevelMenu", logBtn); } catch { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch { } } MoodIconPanel.Children.Add(logBtn); } /// 실행 이력 상세도 팝업 메뉴를 표시합니다. private void ShowLogLevelMenu() { FormatMenuItems.Children.Clear(); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var levels = new (string Key, string Label, string Desc)[] { ("hidden", "Hidden (숨김)", "실행 로그를 표시하지 않음"), ("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"), ("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"), ("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"), }; var current = _settings.Settings.Llm.AgentLogLevel ?? "detailed"; 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 = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg; item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent; item.MouseLeftButtonUp += (_, _) => { _settings.Settings.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 { } FormatMenuPopup.IsOpen = true; } private void ShowLanguageMenu() { FormatMenuItems.Children.Clear(); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; 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 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB"); var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC"); 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 = s_segoeIconFont, 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, }); var chip = new Border { Child = sp, Background = Brushes.Transparent, BorderBrush = borderColor, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(999), Padding = new Thickness(10, 5, 10, 5), Margin = new Thickness(0, 0, 4, 0), Cursor = Cursors.Hand, ToolTip = tooltip, }; chip.MouseEnter += (_, _) => chip.Background = hoverBackground; chip.MouseLeave += (_, _) => chip.Background = Brushes.Transparent; return chip; } 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 = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var currentFormat = _settings.Settings.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 = s_segoeIconFont, 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; _settings.Settings.Llm.DefaultOutputFormat = capturedKey; _settings.Save(); RefreshOverlaySettingsPanel(); BuildBottomBar(); }; FormatMenuItems.Children.Add(itemBorder); } // PlacementTarget을 동적 등록된 버튼으로 설정 if (FormatMenuPopup.PlacementTarget == null && FindName("BtnFormatMenu") is UIElement formatTarget) FormatMenuPopup.PlacementTarget = formatTarget; FormatMenuPopup.IsOpen = true; } private void BtnOverlayDefaultOutputFormat_Click(object sender, RoutedEventArgs e) { if (sender is UIElement element) FormatMenuPopup.PlacementTarget = element; ShowFormatMenu(); } /// 디자인 무드 선택 팝업 메뉴를 표시합니다. private void ShowMoodMenu() { MoodMenuItems.Children.Clear(); var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; // 2열 갤러리 그리드 var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 }; foreach (var mood in TemplateService.AllMoods) { var isActive = _selectedMood == mood.Key; var isCustom = _settings.Settings.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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Background)), BorderBrush = isActive ? accentBrush : new SolidColorBrush((Color)ColorConverter.ConvertFromString(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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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((Color)ColorConverter.ConvertFromString(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 = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.CardBg)), BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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; _settings.Settings.Llm.DefaultMood = capturedMood.Key; _settings.Save(); SaveConversationSettings(); RefreshOverlaySettingsPanel(); 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 = s_segoeIconFont, 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 (MoodMenuPopup.PlacementTarget == null && FindName("BtnMoodMenu") is UIElement moodTarget) MoodMenuPopup.PlacementTarget = moodTarget; MoodMenuPopup.IsOpen = true; } private void BtnOverlayDefaultMood_Click(object sender, RoutedEventArgs e) { if (sender is UIElement element) MoodMenuPopup.PlacementTarget = element; ShowMoodMenu(); } /// 커스텀 무드 추가/편집 다이얼로그를 표시합니다. 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 { _settings.Settings.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(_settings.Settings.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 = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; 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 = _settings.Settings.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) { _settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey); if (_selectedMood == moodKey) _selectedMood = "modern"; _settings.Save(); TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods); BuildBottomBar(); } }; stack.Children.Add(deleteItem); menuBorder.Child = stack; popup.Child = menuBorder; popup.IsOpen = true; } private string? _promptCardPlaceholder; private void ShowPlaceholder() { RefreshInputWatermarkText(); InputWatermark.Visibility = Visibility.Visible; InputBox.Text = ""; InputBox.Focus(); } private void UpdateWatermarkVisibility() { // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) if (_slashPalette.ActiveCommand != null) { InputWatermark.Visibility = Visibility.Collapsed; return; } RefreshInputWatermarkText(); if (string.IsNullOrEmpty(InputBox.Text)) InputWatermark.Visibility = Visibility.Visible; else InputWatermark.Visibility = Visibility.Collapsed; } private void ClearPromptCardPlaceholder() { _promptCardPlaceholder = null; RefreshInputWatermarkText(); UpdateWatermarkVisibility(); } private void BtnSettings_Click(object sender, RoutedEventArgs e) { OpenAgentSettingsWindow(); } }