();
-
- /// 자동 활성화 안내 (when_to_use). AI가 이 스킬을 선제적으로 사용할 힌트.
- public string WhenToUse { get; init; } = "";
-
- /// 스킬 버전.
- public string Version { get; init; } = "";
-
- /// 런타임 의존성 충족 여부. Requires가 비어있으면 항상 true.
- public bool IsAvailable { get; set; } = true;
-
- /// context:fork 설정인지 여부.
- public bool IsForkContext => "fork".Equals(Context, StringComparison.OrdinalIgnoreCase);
-
- /// 지정 탭에서 이 스킬을 표시할지 판정합니다.
- public bool IsVisibleInTab(string activeTab)
- {
- if (string.IsNullOrEmpty(Tabs) || Tabs.Equals("all", StringComparison.OrdinalIgnoreCase))
- return true;
- var tabs = Tabs.Split(',').Select(t => t.Trim().ToLowerInvariant());
- var tab = activeTab.ToLowerInvariant();
- return tabs.Any(t => t == "all" || t == tab);
- }
-
- /// SKILL.md 표준 폴더 형식인지 여부.
- public bool IsStandardFormat => FilePath.EndsWith("SKILL.md", StringComparison.OrdinalIgnoreCase);
-
- /// 비가용 시 사용자에게 표시할 힌트 메시지.
- public string UnavailableHint
- {
- get
- {
- if (IsAvailable || string.IsNullOrEmpty(Requires)) return "";
- var runtimes = Requires.Split(',').Select(r => r.Trim());
- var missing = runtimes.Where(r => !RuntimeDetector.IsAvailable(r)).ToArray();
- return missing.Length > 0 ? $"({string.Join(", ", missing.Select(r => char.ToUpper(r[0]) + r[1..]))} 필요)" : "";
- }
- }
-}
diff --git a/src/AxCopilot/Views/ChatWindow.ConversationExport.cs b/src/AxCopilot/Views/ChatWindow.ConversationExport.cs
new file mode 100644
index 0000000..8e1e753
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.ConversationExport.cs
@@ -0,0 +1,188 @@
+using System.Windows;
+using AxCopilot.Models;
+using AxCopilot.Services;
+
+namespace AxCopilot.Views;
+
+public partial class ChatWindow
+{
+ // ─── 대화 분기 (Fork) ──────────────────────────────────────────────
+
+ private void ForkConversation(ChatConversation source, int atIndex)
+ {
+ var branchCount = _storage.LoadAllMeta()
+ .Count(m => m.ParentId == source.Id) + 1;
+
+ var fork = new ChatConversation
+ {
+ Title = $"{source.Title} (분기 {branchCount})",
+ Tab = source.Tab,
+ Category = source.Category,
+ WorkFolder = source.WorkFolder,
+ SystemCommand = source.SystemCommand,
+ ParentId = source.Id,
+ BranchLabel = $"분기 {branchCount}",
+ BranchAtIndex = atIndex,
+ };
+
+ // 분기 시점까지의 메시지 복제
+ for (int i = 0; i <= atIndex && i < source.Messages.Count; i++)
+ {
+ var m = source.Messages[i];
+ fork.Messages.Add(new ChatMessage
+ {
+ Role = m.Role,
+ Content = m.Content,
+ Timestamp = m.Timestamp,
+ });
+ }
+
+ try
+ {
+ _storage.Save(fork);
+ ShowToast($"분기 생성: {fork.Title}");
+
+ // 분기 대화로 전환
+ lock (_convLock) _currentConversation = fork;
+ ChatTitle.Text = fork.Title;
+ RenderMessages();
+ RefreshConversationList();
+ }
+ catch (Exception ex)
+ {
+ ShowToast($"분기 실패: {ex.Message}", "\uE783");
+ }
+ }
+
+ // ─── 커맨드 팔레트 ─────────────────────────────────────────────────
+
+ private void OpenCommandPalette()
+ {
+ var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this };
+ palette.ShowDialog();
+ }
+
+ private void ExecuteCommand(string commandId)
+ {
+ switch (commandId)
+ {
+ case "tab:chat": TabChat.IsChecked = true; break;
+ case "tab:cowork": TabCowork.IsChecked = true; break;
+ case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break;
+ case "new_conversation": StartNewConversation(); break;
+ case "search_conversation": ToggleMessageSearch(); break;
+ case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break;
+ case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break;
+ case "open_statistics": new StatisticsWindow().Show(); break;
+ case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break;
+ case "toggle_devmode":
+ var llm = Llm;
+ llm.DevMode = !llm.DevMode;
+ _settings.Save();
+ UpdateAnalyzerButtonVisibility();
+ ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
+ break;
+ case "open_audit_log":
+ try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { /* 감사 로그 폴더 열기 실패 */ }
+ break;
+ case "paste_clipboard":
+ try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch (Exception) { /* 클립보드 접근 실패 */ }
+ break;
+ case "export_conversation": ExportConversation(); break;
+ }
+ }
+
+ private void ExportConversation()
+ {
+ ChatConversation? conv;
+ lock (_convLock) conv = _currentConversation;
+ if (conv == null || conv.Messages.Count == 0) return;
+
+ var dlg = new Microsoft.Win32.SaveFileDialog
+ {
+ FileName = $"{conv.Title}",
+ DefaultExt = ".md",
+ Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt"
+ };
+ if (dlg.ShowDialog() != true) return;
+
+ var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant();
+ string content;
+
+ if (ext == ".json")
+ {
+ content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ });
+ }
+ else if (dlg.FileName.EndsWith(".pdf.html"))
+ {
+ // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시
+ content = PdfExportService.BuildHtml(conv);
+ System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
+ PdfExportService.OpenInBrowser(dlg.FileName);
+ ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다");
+ return;
+ }
+ else if (ext == ".html")
+ {
+ content = ExportToHtml(conv);
+ }
+ else
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"# {conv.Title}");
+ sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_");
+ sb.AppendLine();
+
+ foreach (var msg in conv.Messages)
+ {
+ if (msg.Role == "system") continue;
+ var label = msg.Role == "user" ? "**사용자**" : "**AI**";
+ sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})");
+ sb.AppendLine();
+ sb.AppendLine(msg.Content);
+ if (msg.AttachedFiles is { Count: > 0 })
+ {
+ sb.AppendLine();
+ sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_");
+ }
+ sb.AppendLine();
+ sb.AppendLine("---");
+ sb.AppendLine();
+ }
+ content = sb.ToString();
+ }
+
+ System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
+ }
+
+ private static string ExportToHtml(ChatConversation conv)
+ {
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("");
+ sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}");
+ sb.AppendLine("");
+ sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}
");
+ sb.AppendLine($"생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}
");
+
+ foreach (var msg in conv.Messages)
+ {
+ if (msg.Role == "system") continue;
+ var cls = msg.Role == "user" ? "user" : "ai";
+ var label = msg.Role == "user" ? "사용자" : "AI";
+ sb.AppendLine($"");
+ sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
");
+ sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
");
+ sb.AppendLine("
");
+ }
+
+ sb.AppendLine("");
+ return sb.ToString();
+ }
+}
diff --git a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
index 7920b87..ad6b0e7 100644
--- a/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
+++ b/src/AxCopilot/Views/ChatWindow.PreviewAndFiles.cs
@@ -459,251 +459,4 @@ public partial class ChatWindow
System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
}
}
-
- /// 프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.
- private Popup? _previewTabPopup;
-
- private void ShowPreviewTabContextMenu(string filePath)
- {
- // 기존 팝업 닫기
- if (_previewTabPopup != null) _previewTabPopup.IsOpen = false;
-
- var bg = ThemeResourceHelper.Background(this);
- var borderBrush = ThemeResourceHelper.Border(this);
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var hoverBg = ThemeResourceHelper.HoverBg(this);
-
- var stack = new StackPanel();
-
- void AddItem(string icon, string iconColor, string label, Action action)
- {
- var itemBorder = new Border
- {
- Background = Brushes.Transparent,
- CornerRadius = new CornerRadius(6),
- Padding = new Thickness(10, 7, 16, 7),
- Cursor = Cursors.Hand,
- };
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
- sp.Children.Add(new TextBlock
- {
- Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor)
- ? secondaryText
- : ThemeResourceHelper.HexBrush(iconColor),
- VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0),
- });
- sp.Children.Add(new TextBlock
- {
- Text = label, FontSize = 13, Foreground = primaryText,
- VerticalAlignment = VerticalAlignment.Center,
- });
- itemBorder.Child = sp;
- itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
- itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
- itemBorder.MouseLeftButtonUp += (_, _) =>
- {
- _previewTabPopup!.IsOpen = false;
- action();
- };
- stack.Children.Add(itemBorder);
- }
-
- void AddSeparator()
- {
- stack.Children.Add(new Border
- {
- Height = 1,
- Background = borderBrush,
- Margin = new Thickness(8, 3, 8, 3),
- });
- }
-
- AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () =>
- {
- try
- {
- System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
- {
- FileName = filePath, UseShellExecute = true,
- });
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- });
-
- AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
- {
- try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- });
-
- AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath));
-
- AddSeparator();
-
- AddItem("\uE8C8", "", "경로 복사", () =>
- {
- try { Clipboard.SetText(filePath); } catch (Exception) { /* 클립보드 접근 실패 */ }
- });
-
- AddSeparator();
-
- AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath));
-
- if (_previewTabs.Count > 1)
- {
- AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () =>
- {
- var keep = filePath;
- _previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase));
- _activePreviewTab = keep;
- RebuildPreviewTabs();
- LoadPreviewContent(keep);
- });
- }
-
- var popupBorder = new Border
- {
- Background = bg,
- BorderBrush = borderBrush,
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(12),
- Padding = new Thickness(4, 6, 4, 6),
- MinWidth = 180,
- Effect = new System.Windows.Media.Effects.DropShadowEffect
- {
- BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4,
- Color = Colors.Black,
- },
- Child = stack,
- };
-
- _previewTabPopup = new Popup
- {
- Child = popupBorder,
- Placement = PlacementMode.MousePoint,
- StaysOpen = false,
- AllowsTransparency = true,
- PopupAnimation = PopupAnimation.Fade,
- };
- _previewTabPopup.IsOpen = true;
- }
-
- /// 프리뷰를 별도 팝업 창에서 엽니다.
- private void OpenPreviewPopupWindow(string filePath)
- {
- if (!System.IO.File.Exists(filePath)) return;
-
- var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
- var fileName = System.IO.Path.GetFileName(filePath);
- var bg = ThemeResourceHelper.Background(this);
- var fg = ThemeResourceHelper.Primary(this);
-
- var win = new Window
- {
- Title = $"미리보기 — {fileName}",
- Width = 900,
- Height = 700,
- WindowStartupLocation = WindowStartupLocation.CenterScreen,
- Background = bg,
- };
-
- FrameworkElement content;
-
- switch (ext)
- {
- case ".html":
- case ".htm":
- var wv = new Microsoft.Web.WebView2.Wpf.WebView2();
- wv.Loaded += async (_, _) =>
- {
- try
- {
- var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
- userDataFolder: WebView2DataFolder);
- await wv.EnsureCoreWebView2Async(env);
- wv.Source = new Uri(filePath);
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- };
- content = wv;
- break;
-
- case ".md":
- var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2();
- var mdMood = _selectedMood;
- mdWv.Loaded += async (_, _) =>
- {
- try
- {
- var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
- userDataFolder: WebView2DataFolder);
- await mdWv.EnsureCoreWebView2Async(env);
- var mdSrc = System.IO.File.ReadAllText(filePath);
- if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000];
- var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
- mdWv.NavigateToString(html);
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- };
- content = mdWv;
- break;
-
- case ".csv":
- var dg = new System.Windows.Controls.DataGrid
- {
- AutoGenerateColumns = true,
- IsReadOnly = true,
- Background = Brushes.Transparent,
- Foreground = Brushes.White,
- BorderThickness = new Thickness(0),
- FontSize = 12,
- };
- try
- {
- var lines = System.IO.File.ReadAllLines(filePath);
- if (lines.Length > 0)
- {
- var dt = new System.Data.DataTable();
- var headers = ParseCsvLine(lines[0]);
- foreach (var h in headers) dt.Columns.Add(h);
- for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
- {
- var vals = ParseCsvLine(lines[i]);
- var row = dt.NewRow();
- for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++)
- row[j] = vals[j];
- dt.Rows.Add(row);
- }
- dg.ItemsSource = dt.DefaultView;
- }
- }
- catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
- content = dg;
- break;
-
- default:
- var text = System.IO.File.ReadAllText(filePath);
- if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)";
- var sv = new ScrollViewer
- {
- VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
- Padding = new Thickness(20),
- Content = new TextBlock
- {
- Text = text,
- TextWrapping = TextWrapping.Wrap,
- FontFamily = ThemeResourceHelper.Consolas,
- FontSize = 13,
- Foreground = fg,
- },
- };
- content = sv;
- break;
- }
-
- win.Content = content;
- win.Show();
- }
}
diff --git a/src/AxCopilot/Views/ChatWindow.PreviewPopup.cs b/src/AxCopilot/Views/ChatWindow.PreviewPopup.cs
new file mode 100644
index 0000000..51f74c6
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.PreviewPopup.cs
@@ -0,0 +1,259 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace AxCopilot.Views;
+
+public partial class ChatWindow
+{
+ // ─── 미리보기 팝업 ─────────────────────────────────────────────────────────
+
+ /// 프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.
+ private Popup? _previewTabPopup;
+
+ private void ShowPreviewTabContextMenu(string filePath)
+ {
+ // 기존 팝업 닫기
+ if (_previewTabPopup != null) _previewTabPopup.IsOpen = false;
+
+ var bg = ThemeResourceHelper.Background(this);
+ var borderBrush = ThemeResourceHelper.Border(this);
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var hoverBg = ThemeResourceHelper.HoverBg(this);
+
+ var stack = new StackPanel();
+
+ void AddItem(string icon, string iconColor, string label, Action action)
+ {
+ var itemBorder = new Border
+ {
+ Background = Brushes.Transparent,
+ CornerRadius = new CornerRadius(6),
+ Padding = new Thickness(10, 7, 16, 7),
+ Cursor = Cursors.Hand,
+ };
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+ sp.Children.Add(new TextBlock
+ {
+ Text = icon, FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor)
+ ? secondaryText
+ : ThemeResourceHelper.HexBrush(iconColor),
+ VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0),
+ });
+ sp.Children.Add(new TextBlock
+ {
+ Text = label, FontSize = 13, Foreground = primaryText,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ itemBorder.Child = sp;
+ itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
+ itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
+ itemBorder.MouseLeftButtonUp += (_, _) =>
+ {
+ _previewTabPopup!.IsOpen = false;
+ action();
+ };
+ stack.Children.Add(itemBorder);
+ }
+
+ void AddSeparator()
+ {
+ stack.Children.Add(new Border
+ {
+ Height = 1,
+ Background = borderBrush,
+ Margin = new Thickness(8, 3, 8, 3),
+ });
+ }
+
+ AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () =>
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = filePath, UseShellExecute = true,
+ });
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ });
+
+ AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
+ {
+ try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ });
+
+ AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath));
+
+ AddSeparator();
+
+ AddItem("\uE8C8", "", "경로 복사", () =>
+ {
+ try { Clipboard.SetText(filePath); } catch (Exception) { /* 클립보드 접근 실패 */ }
+ });
+
+ AddSeparator();
+
+ AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath));
+
+ if (_previewTabs.Count > 1)
+ {
+ AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () =>
+ {
+ var keep = filePath;
+ _previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase));
+ _activePreviewTab = keep;
+ RebuildPreviewTabs();
+ LoadPreviewContent(keep);
+ });
+ }
+
+ var popupBorder = new Border
+ {
+ Background = bg,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(12),
+ Padding = new Thickness(4, 6, 4, 6),
+ MinWidth = 180,
+ Effect = new System.Windows.Media.Effects.DropShadowEffect
+ {
+ BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4,
+ Color = Colors.Black,
+ },
+ Child = stack,
+ };
+
+ _previewTabPopup = new Popup
+ {
+ Child = popupBorder,
+ Placement = PlacementMode.MousePoint,
+ StaysOpen = false,
+ AllowsTransparency = true,
+ PopupAnimation = PopupAnimation.Fade,
+ };
+ _previewTabPopup.IsOpen = true;
+ }
+
+ /// 프리뷰를 별도 팝업 창에서 엽니다.
+ private void OpenPreviewPopupWindow(string filePath)
+ {
+ if (!System.IO.File.Exists(filePath)) return;
+
+ var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
+ var fileName = System.IO.Path.GetFileName(filePath);
+ var bg = ThemeResourceHelper.Background(this);
+ var fg = ThemeResourceHelper.Primary(this);
+
+ var win = new Window
+ {
+ Title = $"미리보기 — {fileName}",
+ Width = 900,
+ Height = 700,
+ WindowStartupLocation = WindowStartupLocation.CenterScreen,
+ Background = bg,
+ };
+
+ FrameworkElement content;
+
+ switch (ext)
+ {
+ case ".html":
+ case ".htm":
+ var wv = new Microsoft.Web.WebView2.Wpf.WebView2();
+ wv.Loaded += async (_, _) =>
+ {
+ try
+ {
+ var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
+ userDataFolder: WebView2DataFolder);
+ await wv.EnsureCoreWebView2Async(env);
+ wv.Source = new Uri(filePath);
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ };
+ content = wv;
+ break;
+
+ case ".md":
+ var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2();
+ var mdMood = _selectedMood;
+ mdWv.Loaded += async (_, _) =>
+ {
+ try
+ {
+ var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
+ userDataFolder: WebView2DataFolder);
+ await mdWv.EnsureCoreWebView2Async(env);
+ var mdSrc = System.IO.File.ReadAllText(filePath);
+ if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000];
+ var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
+ mdWv.NavigateToString(html);
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ };
+ content = mdWv;
+ break;
+
+ case ".csv":
+ var dg = new System.Windows.Controls.DataGrid
+ {
+ AutoGenerateColumns = true,
+ IsReadOnly = true,
+ Background = Brushes.Transparent,
+ Foreground = Brushes.White,
+ BorderThickness = new Thickness(0),
+ FontSize = 12,
+ };
+ try
+ {
+ var lines = System.IO.File.ReadAllLines(filePath);
+ if (lines.Length > 0)
+ {
+ var dt = new System.Data.DataTable();
+ var headers = ParseCsvLine(lines[0]);
+ foreach (var h in headers) dt.Columns.Add(h);
+ for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
+ {
+ var vals = ParseCsvLine(lines[i]);
+ var row = dt.NewRow();
+ for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++)
+ row[j] = vals[j];
+ dt.Rows.Add(row);
+ }
+ dg.ItemsSource = dt.DefaultView;
+ }
+ }
+ catch (Exception) { /* 비핵심 작업 실패 — UI 차단 방지 */ }
+ content = dg;
+ break;
+
+ default:
+ var text = System.IO.File.ReadAllText(filePath);
+ if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)";
+ var sv = new ScrollViewer
+ {
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ Padding = new Thickness(20),
+ Content = new TextBlock
+ {
+ Text = text,
+ TextWrapping = TextWrapping.Wrap,
+ FontFamily = ThemeResourceHelper.Consolas,
+ FontSize = 13,
+ Foreground = fg,
+ },
+ };
+ content = sv;
+ break;
+ }
+
+ win.Content = content;
+ win.Show();
+ }
+}
diff --git a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs
index f784e8b..7cc583f 100644
--- a/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs
+++ b/src/AxCopilot/Views/ChatWindow.ResponseHandling.cs
@@ -1,8 +1,7 @@
-using System.Windows;
+using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
-using System.Windows.Media.Animation;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
@@ -253,7 +252,7 @@ public partial class ChatWindow
FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg);
AutoScrollIfNeeded();
- try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
+ try { _storage.Save(conv); } catch (Exception ex) { LogService.Debug($"대화 저장 실패: {ex.Message}"); }
_tabConversationId[conv.Tab ?? _activeTab] = conv.Id;
RefreshConversationList();
}
@@ -267,475 +266,4 @@ public partial class ChatWindow
var maxW = (scrollWidth - 120) * 0.90;
return Math.Clamp(maxW, 500, 1200);
}
-
- private StackPanel CreateStreamingContainer(out TextBlock streamText)
- {
- var msgMaxWidth = GetMessageMaxWidth();
- var container = new StackPanel
- {
- HorizontalAlignment = HorizontalAlignment.Left,
- Width = msgMaxWidth,
- MaxWidth = msgMaxWidth,
- Margin = new Thickness(40, 8, 80, 8),
- Opacity = 0,
- RenderTransform = new TranslateTransform(0, 10)
- };
-
- // 컨테이너 페이드인 + 슬라이드 업
- container.BeginAnimation(UIElement.OpacityProperty,
- new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
- ((TranslateTransform)container.RenderTransform).BeginAnimation(
- TranslateTransform.YProperty,
- new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
- { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
-
- var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
-
- var aiIcon = new TextBlock
- {
- Text = "\uE8BD", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12,
- Foreground = ThemeResourceHelper.Accent(this),
- VerticalAlignment = VerticalAlignment.Center
- };
- // AI 아이콘 펄스 애니메이션 (응답 대기 중)
- aiIcon.BeginAnimation(UIElement.OpacityProperty,
- new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
- { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
- EasingFunction = new SineEase() });
- _activeAiIcon = aiIcon;
- Grid.SetColumn(aiIcon, 0);
- headerGrid.Children.Add(aiIcon);
-
- var (streamAgentName, _, _) = GetAgentIdentity();
- var aiNameTb = new TextBlock
- {
- Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold,
- Foreground = ThemeResourceHelper.Accent(this),
- Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
- };
- Grid.SetColumn(aiNameTb, 1);
- headerGrid.Children.Add(aiNameTb);
-
- // 실시간 경과 시간 (헤더 우측)
- _elapsedLabel = new TextBlock
- {
- Text = "0s",
- FontSize = 10.5,
- Foreground = ThemeResourceHelper.Secondary(this),
- HorizontalAlignment = HorizontalAlignment.Right,
- VerticalAlignment = VerticalAlignment.Center,
- Opacity = 0.5,
- };
- Grid.SetColumn(_elapsedLabel, 2);
- headerGrid.Children.Add(_elapsedLabel);
-
- container.Children.Add(headerGrid);
-
- streamText = new TextBlock
- {
- Text = "\u258c", // 블록 커서만 표시 (첫 청크 전)
- FontSize = 13.5,
- Foreground = ThemeResourceHelper.Secondary(this),
- TextWrapping = TextWrapping.Wrap, LineHeight = 22,
- };
- container.Children.Add(streamText);
- return container;
- }
-
- // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
-
- private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
- {
- // 스트리밍 plaintext 블록 제거
- container.Children.Remove(streamText);
-
- // 마크다운 렌더링
- var primaryText = ThemeResourceHelper.Primary(this);
- var secondaryText = ThemeResourceHelper.Secondary(this);
- var accentBrush = ThemeResourceHelper.Accent(this);
- var codeBgBrush = ThemeResourceHelper.Hint(this);
-
- var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
- mdPanel.Margin = new Thickness(0, 0, 0, 4);
- mdPanel.Opacity = 0;
- container.Children.Add(mdPanel);
- mdPanel.BeginAnimation(UIElement.OpacityProperty,
- new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
-
- // 액션 버튼 바 + 토큰 표시
- var btnColor = ThemeResourceHelper.Secondary(this);
- var capturedContent = finalContent;
- var actionBar = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 6, 0, 0)
- };
- actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
- {
- try { Clipboard.SetText(capturedContent); } catch (Exception) { /* 클립보드 접근 실패 */ }
- }));
- actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
- actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
- AddLinkedFeedbackButtons(actionBar, btnColor, message);
-
- container.Children.Add(actionBar);
-
- // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
- var elapsed = DateTime.UtcNow - _streamStartTime;
- var elapsedText = elapsed.TotalSeconds < 60
- ? $"{elapsed.TotalSeconds:0.#}s"
- : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
-
- var usage = _llm.LastTokenUsage;
- // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
- var isAgentTab = _activeTab is "Cowork" or "Code";
- var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
- ? _agentCumulativeInputTokens
- : usage?.PromptTokens ?? 0;
- var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
- ? _agentCumulativeOutputTokens
- : usage?.CompletionTokens ?? 0;
-
- if (displayInput > 0 || displayOutput > 0)
- {
- UpdateStatusTokens(displayInput, displayOutput);
- Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput);
- }
- string tokenText;
- if (displayInput > 0 || displayOutput > 0)
- tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
- else if (usage != null)
- tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
- else
- tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
-
- var metaText = new TextBlock
- {
- Text = $"{elapsedText} · {tokenText}",
- FontSize = 10.5,
- Foreground = ThemeResourceHelper.Secondary(this),
- HorizontalAlignment = HorizontalAlignment.Right,
- Margin = new Thickness(0, 6, 0, 0),
- Opacity = 0.6,
- };
- container.Children.Add(metaText);
-
- // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
- var chips = ParseSuggestionChips(finalContent);
- if (chips.Count > 0)
- {
- var chipPanel = new WrapPanel
- {
- Margin = new Thickness(0, 8, 0, 4),
- HorizontalAlignment = HorizontalAlignment.Left,
- };
- foreach (var (num, label) in chips)
- {
- var chipBorder = new Border
- {
- Background = ThemeResourceHelper.ItemBg(this),
- BorderBrush = ThemeResourceHelper.Border(this),
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(16),
- Padding = new Thickness(14, 7, 14, 7),
- Margin = new Thickness(0, 0, 8, 6),
- Cursor = Cursors.Hand,
- RenderTransformOrigin = new Point(0.5, 0.5),
- RenderTransform = new ScaleTransform(1, 1),
- };
- chipBorder.Child = new TextBlock
- {
- Text = $"{num}. {label}",
- FontSize = 12.5,
- Foreground = ThemeResourceHelper.Primary(this),
- };
-
- var chipHover = ThemeResourceHelper.HoverBg(this);
- var chipNormal = ThemeResourceHelper.ItemBg(this);
- chipBorder.MouseEnter += (s, _) =>
- {
- if (s is Border b && b.RenderTransform is ScaleTransform st)
- { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
- };
- chipBorder.MouseLeave += (s, _) =>
- {
- if (s is Border b && b.RenderTransform is ScaleTransform st)
- { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
- };
-
- var capturedLabel = $"{num}. {label}";
- var capturedPanel = chipPanel;
- chipBorder.MouseLeftButtonDown += (_, _) =>
- {
- // 칩 패널 제거 (1회용)
- if (capturedPanel.Parent is Panel parent)
- parent.Children.Remove(capturedPanel);
- // 선택한 옵션을 사용자 메시지로 전송
- InputBox.Text = capturedLabel;
- _ = SendMessageAsync();
- };
- chipPanel.Children.Add(chipBorder);
- }
- container.Children.Add(chipPanel);
- }
- }
-
- /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)
- private static List<(string Num, string Label)> ParseSuggestionChips(string content)
- {
- var chips = new List<(string, string)>();
- if (string.IsNullOrEmpty(content)) return chips;
-
- var lines = content.Split('\n');
- // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
- var candidates = new List<(string, string)>();
- var lastBlockStart = -1;
-
- for (int i = 0; i < lines.Length; i++)
- {
- var line = lines[i].Trim();
- // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
- var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
- if (m.Success)
- {
- if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
- {
- if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
- candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
- }
- else
- {
- // 새로운 블록 시작
- lastBlockStart = i;
- candidates.Clear();
- candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
- }
- }
- else if (!string.IsNullOrWhiteSpace(line))
- {
- // 번호 목록이 아닌 줄이 나오면 블록 리셋
- lastBlockStart = -1;
- candidates.Clear();
- }
- // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
- }
-
- // 2개 이상 선택지, 10개 이하일 때만 chips로 표시
- if (candidates.Count >= 2 && candidates.Count <= 10)
- chips.AddRange(candidates);
-
- return chips;
- }
-
- /// 토큰 수를 k/m 단위로 포맷
- private static string FormatTokenCount(int count) => count switch
- {
- >= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
- >= 1_000 => $"{count / 1_000.0:0.#}k",
- _ => count.ToString(),
- };
-
- /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)
- private static int EstimateTokenCount(string text)
- {
- if (string.IsNullOrEmpty(text)) return 0;
- // 한국어 문자 비율에 따라 가중
- int cjk = 0;
- foreach (var c in text)
- if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
- double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
- double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
- return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
- }
-
- // ─── 생성 중지 ──────────────────────────────────────────────────────
-
- private void StopGeneration()
- {
- _streamCts?.Cancel();
- }
-
- // ─── 대화 분기 (Fork) ──────────────────────────────────────────────
-
- private void ForkConversation(ChatConversation source, int atIndex)
- {
- var branchCount = _storage.LoadAllMeta()
- .Count(m => m.ParentId == source.Id) + 1;
-
- var fork = new ChatConversation
- {
- Title = $"{source.Title} (분기 {branchCount})",
- Tab = source.Tab,
- Category = source.Category,
- WorkFolder = source.WorkFolder,
- SystemCommand = source.SystemCommand,
- ParentId = source.Id,
- BranchLabel = $"분기 {branchCount}",
- BranchAtIndex = atIndex,
- };
-
- // 분기 시점까지의 메시지 복제
- for (int i = 0; i <= atIndex && i < source.Messages.Count; i++)
- {
- var m = source.Messages[i];
- fork.Messages.Add(new ChatMessage
- {
- Role = m.Role,
- Content = m.Content,
- Timestamp = m.Timestamp,
- });
- }
-
- try
- {
- _storage.Save(fork);
- ShowToast($"분기 생성: {fork.Title}");
-
- // 분기 대화로 전환
- lock (_convLock) _currentConversation = fork;
- ChatTitle.Text = fork.Title;
- RenderMessages();
- RefreshConversationList();
- }
- catch (Exception ex)
- {
- ShowToast($"분기 실패: {ex.Message}", "\uE783");
- }
- }
-
- // ─── 커맨드 팔레트 ─────────────────────────────────────────────────
-
- private void OpenCommandPalette()
- {
- var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this };
- palette.ShowDialog();
- }
-
- private void ExecuteCommand(string commandId)
- {
- switch (commandId)
- {
- case "tab:chat": TabChat.IsChecked = true; break;
- case "tab:cowork": TabCowork.IsChecked = true; break;
- case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break;
- case "new_conversation": StartNewConversation(); break;
- case "search_conversation": ToggleMessageSearch(); break;
- case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break;
- case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break;
- case "open_statistics": new StatisticsWindow().Show(); break;
- case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break;
- case "toggle_devmode":
- var llm = Llm;
- llm.DevMode = !llm.DevMode;
- _settings.Save();
- UpdateAnalyzerButtonVisibility();
- ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
- break;
- case "open_audit_log":
- try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch (Exception) { /* 감사 로그 폴더 열기 실패 */ }
- break;
- case "paste_clipboard":
- try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch (Exception) { /* 클립보드 접근 실패 */ }
- break;
- case "export_conversation": ExportConversation(); break;
- }
- }
-
- private void ExportConversation()
- {
- ChatConversation? conv;
- lock (_convLock) conv = _currentConversation;
- if (conv == null || conv.Messages.Count == 0) return;
-
- var dlg = new Microsoft.Win32.SaveFileDialog
- {
- FileName = $"{conv.Title}",
- DefaultExt = ".md",
- Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt"
- };
- if (dlg.ShowDialog() != true) return;
-
- var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant();
- string content;
-
- if (ext == ".json")
- {
- content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
- });
- }
- else if (dlg.FileName.EndsWith(".pdf.html"))
- {
- // PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시
- content = PdfExportService.BuildHtml(conv);
- System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
- PdfExportService.OpenInBrowser(dlg.FileName);
- ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다");
- return;
- }
- else if (ext == ".html")
- {
- content = ExportToHtml(conv);
- }
- else
- {
- var sb = new System.Text.StringBuilder();
- sb.AppendLine($"# {conv.Title}");
- sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_");
- sb.AppendLine();
-
- foreach (var msg in conv.Messages)
- {
- if (msg.Role == "system") continue;
- var label = msg.Role == "user" ? "**사용자**" : "**AI**";
- sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})");
- sb.AppendLine();
- sb.AppendLine(msg.Content);
- if (msg.AttachedFiles is { Count: > 0 })
- {
- sb.AppendLine();
- sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_");
- }
- sb.AppendLine();
- sb.AppendLine("---");
- sb.AppendLine();
- }
- content = sb.ToString();
- }
-
- System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
- }
-
- private static string ExportToHtml(ChatConversation conv)
- {
- var sb = new System.Text.StringBuilder();
- sb.AppendLine("");
- sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}");
- sb.AppendLine("");
- sb.AppendLine($"{System.Net.WebUtility.HtmlEncode(conv.Title)}
");
- sb.AppendLine($"생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}
");
-
- foreach (var msg in conv.Messages)
- {
- if (msg.Role == "system") continue;
- var cls = msg.Role == "user" ? "user" : "ai";
- var label = msg.Role == "user" ? "사용자" : "AI";
- sb.AppendLine($"");
- sb.AppendLine($"
{label} · {msg.Timestamp:HH:mm}
");
- sb.AppendLine($"
{System.Net.WebUtility.HtmlEncode(msg.Content)}
");
- sb.AppendLine("
");
- }
-
- sb.AppendLine("");
- return sb.ToString();
- }
}
diff --git a/src/AxCopilot/Views/ChatWindow.StreamingUI.cs b/src/AxCopilot/Views/ChatWindow.StreamingUI.cs
new file mode 100644
index 0000000..4d2ecc3
--- /dev/null
+++ b/src/AxCopilot/Views/ChatWindow.StreamingUI.cs
@@ -0,0 +1,303 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using AxCopilot.Models;
+using AxCopilot.Services;
+
+namespace AxCopilot.Views;
+
+public partial class ChatWindow
+{
+ private StackPanel CreateStreamingContainer(out TextBlock streamText)
+ {
+ var msgMaxWidth = GetMessageMaxWidth();
+ var container = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Width = msgMaxWidth,
+ MaxWidth = msgMaxWidth,
+ Margin = new Thickness(40, 8, 80, 8),
+ Opacity = 0,
+ RenderTransform = new TranslateTransform(0, 10)
+ };
+
+ // 컨테이너 페이드인 + 슬라이드 업
+ container.BeginAnimation(UIElement.OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
+ ((TranslateTransform)container.RenderTransform).BeginAnimation(
+ TranslateTransform.YProperty,
+ new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
+ { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
+
+ var headerGrid = new Grid { Margin = new Thickness(0, 0, 0, 4) };
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ var aiIcon = new TextBlock
+ {
+ Text = "\uE8BD", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 12,
+ Foreground = ThemeResourceHelper.Accent(this),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ // AI 아이콘 펄스 애니메이션 (응답 대기 중)
+ aiIcon.BeginAnimation(UIElement.OpacityProperty,
+ new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
+ { AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
+ EasingFunction = new SineEase() });
+ _activeAiIcon = aiIcon;
+ Grid.SetColumn(aiIcon, 0);
+ headerGrid.Children.Add(aiIcon);
+
+ var (streamAgentName, _, _) = GetAgentIdentity();
+ var aiNameTb = new TextBlock
+ {
+ Text = streamAgentName, FontSize = 11, FontWeight = FontWeights.SemiBold,
+ Foreground = ThemeResourceHelper.Accent(this),
+ Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
+ };
+ Grid.SetColumn(aiNameTb, 1);
+ headerGrid.Children.Add(aiNameTb);
+
+ // 실시간 경과 시간 (헤더 우측)
+ _elapsedLabel = new TextBlock
+ {
+ Text = "0s",
+ FontSize = 10.5,
+ Foreground = ThemeResourceHelper.Secondary(this),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Center,
+ Opacity = 0.5,
+ };
+ Grid.SetColumn(_elapsedLabel, 2);
+ headerGrid.Children.Add(_elapsedLabel);
+
+ container.Children.Add(headerGrid);
+
+ streamText = new TextBlock
+ {
+ Text = "\u258c", // 블록 커서만 표시 (첫 청크 전)
+ FontSize = 13.5,
+ Foreground = ThemeResourceHelper.Secondary(this),
+ TextWrapping = TextWrapping.Wrap, LineHeight = 22,
+ };
+ container.Children.Add(streamText);
+ return container;
+ }
+
+ // ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
+
+ private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
+ {
+ // 스트리밍 plaintext 블록 제거
+ container.Children.Remove(streamText);
+
+ // 마크다운 렌더링
+ var primaryText = ThemeResourceHelper.Primary(this);
+ var secondaryText = ThemeResourceHelper.Secondary(this);
+ var accentBrush = ThemeResourceHelper.Accent(this);
+ var codeBgBrush = ThemeResourceHelper.Hint(this);
+
+ var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
+ mdPanel.Margin = new Thickness(0, 0, 0, 4);
+ mdPanel.Opacity = 0;
+ container.Children.Add(mdPanel);
+ mdPanel.BeginAnimation(UIElement.OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
+
+ // 액션 버튼 바 + 토큰 표시
+ var btnColor = ThemeResourceHelper.Secondary(this);
+ var capturedContent = finalContent;
+ var actionBar = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(0, 6, 0, 0)
+ };
+ actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
+ {
+ try { Clipboard.SetText(capturedContent); } catch (Exception) { /* 클립보드 접근 실패 */ }
+ }));
+ actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
+ actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
+ AddLinkedFeedbackButtons(actionBar, btnColor, message);
+
+ container.Children.Add(actionBar);
+
+ // 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
+ var elapsed = DateTime.UtcNow - _streamStartTime;
+ var elapsedText = elapsed.TotalSeconds < 60
+ ? $"{elapsed.TotalSeconds:0.#}s"
+ : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
+
+ var usage = _llm.LastTokenUsage;
+ // 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
+ var isAgentTab = _activeTab is "Cowork" or "Code";
+ var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
+ ? _agentCumulativeInputTokens
+ : usage?.PromptTokens ?? 0;
+ var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
+ ? _agentCumulativeOutputTokens
+ : usage?.CompletionTokens ?? 0;
+
+ if (displayInput > 0 || displayOutput > 0)
+ {
+ UpdateStatusTokens(displayInput, displayOutput);
+ Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput);
+ }
+ string tokenText;
+ if (displayInput > 0 || displayOutput > 0)
+ tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
+ else if (usage != null)
+ tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
+ else
+ tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
+
+ var metaText = new TextBlock
+ {
+ Text = $"{elapsedText} · {tokenText}",
+ FontSize = 10.5,
+ Foreground = ThemeResourceHelper.Secondary(this),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 6, 0, 0),
+ Opacity = 0.6,
+ };
+ container.Children.Add(metaText);
+
+ // Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
+ var chips = ParseSuggestionChips(finalContent);
+ if (chips.Count > 0)
+ {
+ var chipPanel = new WrapPanel
+ {
+ Margin = new Thickness(0, 8, 0, 4),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ };
+ foreach (var (num, label) in chips)
+ {
+ var chipBorder = new Border
+ {
+ Background = ThemeResourceHelper.ItemBg(this),
+ BorderBrush = ThemeResourceHelper.Border(this),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(16),
+ Padding = new Thickness(14, 7, 14, 7),
+ Margin = new Thickness(0, 0, 8, 6),
+ Cursor = Cursors.Hand,
+ RenderTransformOrigin = new Point(0.5, 0.5),
+ RenderTransform = new ScaleTransform(1, 1),
+ };
+ chipBorder.Child = new TextBlock
+ {
+ Text = $"{num}. {label}",
+ FontSize = 12.5,
+ Foreground = ThemeResourceHelper.Primary(this),
+ };
+
+ var chipHover = ThemeResourceHelper.HoverBg(this);
+ var chipNormal = ThemeResourceHelper.ItemBg(this);
+ chipBorder.MouseEnter += (s, _) =>
+ {
+ if (s is Border b && b.RenderTransform is ScaleTransform st)
+ { st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
+ };
+ chipBorder.MouseLeave += (s, _) =>
+ {
+ if (s is Border b && b.RenderTransform is ScaleTransform st)
+ { st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
+ };
+
+ var capturedLabel = $"{num}. {label}";
+ var capturedPanel = chipPanel;
+ chipBorder.MouseLeftButtonDown += (_, _) =>
+ {
+ // 칩 패널 제거 (1회용)
+ if (capturedPanel.Parent is Panel parent)
+ parent.Children.Remove(capturedPanel);
+ // 선택한 옵션을 사용자 메시지로 전송
+ InputBox.Text = capturedLabel;
+ _ = SendMessageAsync();
+ };
+ chipPanel.Children.Add(chipBorder);
+ }
+ container.Children.Add(chipPanel);
+ }
+ }
+
+ /// AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)
+ private static List<(string Num, string Label)> ParseSuggestionChips(string content)
+ {
+ var chips = new List<(string, string)>();
+ if (string.IsNullOrEmpty(content)) return chips;
+
+ var lines = content.Split('\n');
+ // 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
+ var candidates = new List<(string, string)>();
+ var lastBlockStart = -1;
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i].Trim();
+ // "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
+ var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
+ if (m.Success)
+ {
+ if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
+ {
+ if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
+ candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
+ }
+ else
+ {
+ // 새로운 블록 시작
+ lastBlockStart = i;
+ candidates.Clear();
+ candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
+ }
+ }
+ else if (!string.IsNullOrWhiteSpace(line))
+ {
+ // 번호 목록이 아닌 줄이 나오면 블록 리셋
+ lastBlockStart = -1;
+ candidates.Clear();
+ }
+ // 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
+ }
+
+ // 2개 이상 선택지, 10개 이하일 때만 chips로 표시
+ if (candidates.Count >= 2 && candidates.Count <= 10)
+ chips.AddRange(candidates);
+
+ return chips;
+ }
+
+ /// 토큰 수를 k/m 단위로 포맷
+ private static string FormatTokenCount(int count) => count switch
+ {
+ >= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
+ >= 1_000 => $"{count / 1_000.0:0.#}k",
+ _ => count.ToString(),
+ };
+
+ /// 토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)
+ private static int EstimateTokenCount(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return 0;
+ // 한국어 문자 비율에 따라 가중
+ int cjk = 0;
+ foreach (var c in text)
+ if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
+ double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
+ double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
+ return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
+ }
+
+ // ─── 생성 중지 ──────────────────────────────────────────────────────
+
+ private void StopGeneration()
+ {
+ _streamCts?.Cancel();
+ }
+}
diff --git a/src/AxCopilot/Views/HelpDetailWindow.Navigation.cs b/src/AxCopilot/Views/HelpDetailWindow.Navigation.cs
new file mode 100644
index 0000000..96373b8
--- /dev/null
+++ b/src/AxCopilot/Views/HelpDetailWindow.Navigation.cs
@@ -0,0 +1,266 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace AxCopilot.Views;
+
+public partial class HelpDetailWindow
+{
+ // ─── 테마 색상 헬퍼 ───────────────────────────────────────────────────────
+ private Brush ThemeAccent => TryFindResource("AccentColor") as Brush ?? ParseColor("#4B5EFC");
+ private Brush ThemePrimary => TryFindResource("PrimaryText") as Brush ?? Brushes.White;
+ private Brush ThemeSecondary => TryFindResource("SecondaryText") as Brush ?? ParseColor("#8899CC");
+
+ // ─── 상단 3탭 메뉴 빌드 ──────────────────────────────────────────────────
+
+ private void BuildTopMenu()
+ {
+ TopMenuBar.Children.Clear();
+ foreach (var (key, label, icon) in TopMenus)
+ {
+ var k = key;
+ var sp = new StackPanel { Orientation = Orientation.Horizontal };
+ sp.Children.Add(new TextBlock
+ {
+ Text = icon,
+ FontFamily = ThemeResourceHelper.SegoeMdl2,
+ FontSize = 14,
+ Foreground = ThemeAccent,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 6, 0)
+ });
+ sp.Children.Add(new TextBlock
+ {
+ Text = label,
+ FontSize = 12,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+
+ var btn = new Button
+ {
+ Content = sp,
+ FontWeight = FontWeights.SemiBold,
+ Padding = new Thickness(16, 8, 16, 8),
+ Margin = new Thickness(4, 0, 4, 0),
+ Cursor = Cursors.Hand,
+ Background = Brushes.Transparent,
+ Foreground = ThemeSecondary,
+ BorderThickness = new Thickness(0, 0, 0, 2),
+ BorderBrush = Brushes.Transparent,
+ Tag = k,
+ };
+ btn.Click += (_, _) => SwitchTopMenu(k);
+ TopMenuBar.Children.Add(btn);
+ }
+ }
+
+ private void SwitchTopMenu(TopMenu menu)
+ {
+ _currentTopMenu = menu;
+
+ // 상단 메뉴 하이라이트
+ foreach (var child in TopMenuBar.Children)
+ {
+ if (child is Button btn)
+ {
+ var isActive = btn.Tag is TopMenu t && t == menu;
+ btn.BorderBrush = isActive ? ThemeAccent : Brushes.Transparent;
+ btn.Foreground = isActive ? ThemePrimary : ThemeSecondary;
+
+ // 내부 TextBlock의 Foreground도 업데이트
+ if (btn.Content is StackPanel sp)
+ {
+ foreach (var spChild in sp.Children)
+ {
+ if (spChild is TextBlock tb && tb.FontFamily.Source != "Segoe MDL2 Assets")
+ tb.Foreground = btn.Foreground;
+ }
+ }
+ }
+ }
+
+ // 하위 카테고리 탭 빌드
+ switch (menu)
+ {
+ case TopMenu.Overview:
+ BuildCategoryBarFor(_overviewItems);
+ break;
+ case TopMenu.Shortcuts:
+ BuildCategoryBarFor(_shortcutItems);
+ break;
+ case TopMenu.Prefixes:
+ BuildCategoryBarFor(_prefixItems);
+ break;
+ }
+ }
+
+ // ─── 하위 카테고리 탭 빌드 ────────────────────────────────────────────────
+
+ private void BuildCategoryBarFor(List sourceItems)
+ {
+ var seen = new HashSet();
+ _categories = new List { CatAll };
+
+ // 예약어 탭에서만 인기 카테고리 표시
+ if (_currentTopMenu == TopMenu.Prefixes)
+ _categories.Add(CatPopular);
+
+ foreach (var item in sourceItems)
+ {
+ if (seen.Add(item.Category))
+ _categories.Add(item.Category);
+ }
+
+ BuildCategoryBar();
+ NavigateToPage(0);
+ }
+
+ private void BuildCategoryBar()
+ {
+ CategoryBar.Children.Clear();
+ for (int i = 0; i < _categories.Count; i++)
+ {
+ var cat = _categories[i];
+ var idx = i;
+
+ var btn = new Button
+ {
+ Content = cat,
+ FontSize = 10.5,
+ FontWeight = FontWeights.SemiBold,
+ Padding = new Thickness(9, 4, 9, 4),
+ Margin = new Thickness(0, 0, 3, 0),
+ Cursor = Cursors.Hand,
+ Background = Brushes.Transparent,
+ Foreground = ThemeSecondary,
+ BorderThickness = new Thickness(0),
+ Tag = idx,
+ };
+
+ btn.Click += (_, _) => NavigateToPage(idx);
+ CategoryBar.Children.Add(btn);
+ }
+ }
+
+ // ─── 페이지 네비게이션 ──────────────────────────────────────────────────
+
+ private List GetCurrentSourceItems() => _currentTopMenu switch
+ {
+ TopMenu.Overview => _overviewItems,
+ TopMenu.Shortcuts => _shortcutItems,
+ TopMenu.Prefixes => _prefixItems,
+ _ => _overviewItems,
+ };
+
+ private void NavigateToPage(int pageIndex)
+ {
+ if (_categories.Count == 0) return;
+ _currentPage = Math.Clamp(pageIndex, 0, _categories.Count - 1);
+
+ var source = GetCurrentSourceItems();
+ var cat = _categories[_currentPage];
+ List filtered;
+
+ if (cat == CatAll)
+ filtered = source;
+ else if (cat == CatPopular)
+ {
+ var popularCmds = new HashSet(StringComparer.OrdinalIgnoreCase)
+ { "파일/폴더", "?", "#", "!", "clip", "pipe", "diff", "win" };
+ filtered = source.Where(i =>
+ popularCmds.Contains(i.Command) ||
+ i.Command.StartsWith("?") ||
+ i.Title.Contains("검색") ||
+ i.Title.Contains("클립보드") ||
+ (i.Title.Contains("파일") && i.Category != "키보드")
+ ).ToList();
+ }
+ else
+ filtered = source.Where(i => i.Category == cat).ToList();
+
+ ItemsHost.ItemsSource = filtered;
+
+ // 탭 하이라이트
+ for (int i = 0; i < CategoryBar.Children.Count; i++)
+ {
+ if (CategoryBar.Children[i] is Button btn)
+ {
+ if (i == _currentPage)
+ {
+ btn.Background = ThemeAccent;
+ btn.Foreground = Brushes.White;
+ }
+ else
+ {
+ btn.Background = Brushes.Transparent;
+ btn.Foreground = ThemeSecondary;
+ }
+ }
+ }
+
+ PageIndicator.Text = $"{cat} ({filtered.Count}개)";
+ PrevBtn.Visibility = _currentPage > 0 ? Visibility.Visible : Visibility.Hidden;
+ NextBtn.Visibility = _currentPage < _categories.Count - 1 ? Visibility.Visible : Visibility.Hidden;
+ }
+
+ // ─── 이벤트 ────────────────────────────────────────────────────────────
+
+ private void OnKeyDown(object sender, KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Escape:
+ Close();
+ break;
+ case Key.Left:
+ if (_currentPage > 0) NavigateToPage(_currentPage - 1);
+ e.Handled = true;
+ break;
+ case Key.Right:
+ if (_currentPage < _categories.Count - 1) NavigateToPage(_currentPage + 1);
+ e.Handled = true;
+ break;
+ // 1, 2, 3 숫자키로 상단 메뉴 전환
+ case Key.D1: SwitchTopMenu(TopMenu.Overview); e.Handled = true; break;
+ case Key.D2: SwitchTopMenu(TopMenu.Shortcuts); e.Handled = true; break;
+ case Key.D3: SwitchTopMenu(TopMenu.Prefixes); e.Handled = true; break;
+ }
+ }
+
+ private void Prev_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage - 1);
+ private void Next_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage + 1);
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+
+ private void SearchBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
+ {
+ var query = SearchBox.Text.Trim();
+ if (string.IsNullOrEmpty(query))
+ {
+ NavigateToPage(_currentPage);
+ return;
+ }
+
+ var source = GetCurrentSourceItems();
+ var filtered = source.Where(i =>
+ i.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ i.Command.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ i.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
+ i.Category.Contains(query, StringComparison.OrdinalIgnoreCase)
+ ).ToList();
+
+ ItemsHost.ItemsSource = filtered;
+ PageIndicator.Text = $"검색: \"{query}\" ({filtered.Count}개)";
+ }
+
+ private void Window_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed)
+ try { DragMove(); } catch (Exception) { }
+ }
+
+ private void Window_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Escape) Close();
+ }
+}
diff --git a/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs b/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs
new file mode 100644
index 0000000..fcde08a
--- /dev/null
+++ b/src/AxCopilot/Views/HelpDetailWindow.Shortcuts.cs
@@ -0,0 +1,168 @@
+namespace AxCopilot.Views;
+
+public partial class HelpDetailWindow
+{
+ // ─── 단축키 항목 빌드 ─────────────────────────────────────────────────────
+
+ private static List BuildShortcutItems(string globalHotkey = "Alt+Space")
+ {
+ var items = new List();
+
+ // 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "Alt+Space" → "Alt + Space")
+ var hotkeyDisplay = globalHotkey.Replace("+", " + ");
+
+ // ── 전역 단축키 ──────────────────────────────────────────────────────
+ items.Add(MakeShortcut("전역", hotkeyDisplay,
+ "AX Commander 열기/닫기",
+ "어느 창에서든 눌러 AX Commander를 즉시 호출하거나 닫습니다. 설정 › 일반에서 원하는 키 조합으로 변경할 수 있습니다.",
+ "\uE765", "#4B5EFC"));
+ items.Add(MakeShortcut("전역", "PrintScreen",
+ "화면 캡처 즉시 실행",
+ "AX Commander를 열지 않고 곧바로 캡처를 시작합니다. 설정 › 캡처 탭에서 '글로벌 단축키 활성화'를 켜야 동작합니다.",
+ "\uE722", "#BE185D"));
+
+ // ── 런처 탐색 ────────────────────────────────────────────────────────
+ items.Add(MakeShortcut("AX Commander 탐색", "Escape",
+ "창 닫기 / 이전 단계로",
+ "액션 모드(→ 로 진입)에 있을 때는 일반 검색 화면으로 돌아갑니다. 일반 화면이면 AX Commander를 숨깁니다.",
+ "\uE711", "#999999"));
+ items.Add(MakeShortcut("AX Commander 탐색", "Enter",
+ "선택 항목 실행",
+ "파일·앱이면 열기, URL이면 브라우저 열기, 시스템 명령이면 즉시 실행, 계산기 결과면 클립보드에 복사합니다.",
+ "\uE768", "#107C10"));
+ items.Add(MakeShortcut("AX Commander 탐색", "Shift + Enter",
+ "대형 텍스트(Large Type) 표시 / 클립보드 병합 실행",
+ "선택된 텍스트·검색어를 화면 전체에 크게 띄웁니다. 클립보드 병합 항목이 있을 때는 선택한 항목들을 줄바꿈으로 합쳐 클립보드에 복사합니다.",
+ "\uE8C1", "#8764B8"));
+ items.Add(MakeShortcut("AX Commander 탐색", "↑ / ↓",
+ "결과 목록 위/아래 이동",
+ "목록 끝에서 계속 누르면 처음/끝으로 순환합니다.",
+ "\uE74A", "#0078D4"));
+ items.Add(MakeShortcut("AX Commander 탐색", "PageUp / PageDown",
+ "목록 5칸 빠른 이동",
+ "한 번에 5항목씩 건너뜁니다. 빠른 목록 탐색에 유용합니다.",
+ "\uE74A", "#0078D4"));
+ items.Add(MakeShortcut("AX Commander 탐색", "Home / End",
+ "목록 처음 / 마지막 항목으로 점프",
+ "입력창 커서가 맨 앞(또는 입력이 없을 때)이면 첫 항목으로, 맨 끝이면 마지막 항목으로 선택이 이동합니다.",
+ "\uE74A", "#0078D4"));
+ items.Add(MakeShortcut("AX Commander 탐색", "→ (오른쪽 화살표)",
+ "액션 모드 진입",
+ "파일·앱 항목을 선택한 상태에서 → 를 누르면 경로 복사, 탐색기 열기, 관리자 실행, 터미널, 속성, 이름 변경, 삭제 메뉴가 나타납니다.",
+ "\uE76C", "#44546A"));
+ items.Add(MakeShortcut("AX Commander 탐색", "Tab",
+ "선택 항목 제목으로 자동완성",
+ "현재 선택된 항목의 이름을 입력창에 채웁니다. 이후 계속 타이핑하거나 Enter로 실행합니다.",
+ "\uE748", "#006EAF"));
+ items.Add(MakeShortcut("AX Commander 탐색", "Shift + ↑/↓",
+ "클립보드 병합 선택",
+ "클립보드 히스토리(# 모드) 에서 여러 항목을 이동하면서 선택/해제합니다. Shift+Enter로 선택한 항목들을 한 번에 붙여넣을 수 있습니다.",
+ "\uE8C1", "#B7791F"));
+
+ // ── 런처 기능 단축키 ─────────────────────────────────────────────────
+ items.Add(MakeShortcut("런처 기능", "F1",
+ "도움말 창 열기",
+ "이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.",
+ "\uE897", "#6B7280"));
+ items.Add(MakeShortcut("런처 기능", "F2",
+ "선택 파일 이름 변경",
+ "파일·폴더 항목을 선택한 상태에서 누르면 rename [경로] 형태로 입력창에 채워지고 이름 변경 핸들러가 실행됩니다.",
+ "\uE70F", "#6B2C91"));
+ items.Add(MakeShortcut("런처 기능", "F5",
+ "파일 인덱스 즉시 재구축",
+ "백그라운드에서 파일·앱 인덱싱을 다시 실행합니다. 새 파일을 추가했거나 목록이 오래됐을 때 사용합니다.",
+ "\uE72C", "#059669"));
+ items.Add(MakeShortcut("런처 기능", "Delete",
+ "최근 실행 목록에서 항목 제거",
+ "recent 목록에 있는 항목을 제거합니다. 확인 다이얼로그가 표시되며 OK를 눌러야 실제로 제거됩니다.",
+ "\uE74D", "#DC2626"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + ,",
+ "설정 창 열기",
+ "AX Copilot 설정 창을 엽니다. 런처가 자동으로 숨겨집니다.",
+ "\uE713", "#44546A"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + L",
+ "입력창 전체 초기화",
+ "현재 입력된 검색어·예약어를 모두 지우고 커서를 빈 입력창으로 돌립니다.",
+ "\uE894", "#4B5EFC"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + C",
+ "선택 항목 파일 이름 복사",
+ "파일·앱 항목이 선택된 경우 확장자를 제외한 파일 이름을 클립보드에 복사하고 토스트로 알립니다.",
+ "\uE8C8", "#8764B8"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + C",
+ "선택 항목 전체 경로 복사",
+ "선택된 파일·폴더의 절대 경로(예: C:\\Users\\...)를 클립보드에 복사합니다.",
+ "\uE8C8", "#C55A11"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + E",
+ "파일 탐색기에서 선택 항목 열기",
+ "Windows 탐색기가 열리고 해당 파일·폴더가 하이라이트 선택된 상태로 표시됩니다.",
+ "\uE838", "#107C10"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + Enter",
+ "관리자(UAC) 권한으로 실행",
+ "선택된 파일·앱을 UAC 권한 상승 후 실행합니다. 설치 프로그램이나 시스템 설정 앱에 유용합니다.",
+ "\uE7EF", "#C50F1F"));
+ items.Add(MakeShortcut("런처 기능", "Alt + Enter",
+ "파일 속성 대화 상자 열기",
+ "Windows의 '파일 속성' 창(크기·날짜·권한 등)을 엽니다.",
+ "\uE946", "#6B2C91"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + T",
+ "선택 항목 위치에서 터미널 열기",
+ "선택된 파일이면 해당 폴더에서, 폴더이면 그 경로에서 Windows Terminal(wt.exe)이 열립니다. wt가 없으면 cmd로 대체됩니다.",
+ "\uE756", "#323130"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + P",
+ "즐겨찾기 즉시 추가 / 제거 (핀)",
+ "파일·폴더 항목을 선택한 상태에서 누르면 favorites.json 에 추가하거나 이미 있으면 제거합니다. 토스트로 결과를 알립니다.",
+ "\uE734", "#D97706"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + B",
+ "즐겨찾기 목록 보기 / 닫기 토글",
+ "입력창이 'fav' 이면 초기화하고, 아니면 'fav' 를 입력해 즐겨찾기 목록을 표시합니다.",
+ "\uE735", "#D97706"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + R",
+ "최근 실행 목록 보기 / 닫기 토글",
+ "'recent' 를 입력해 최근 실행 항목을 표시합니다.",
+ "\uE81C", "#0078D4"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + H",
+ "클립보드 히스토리 목록 열기",
+ "'#' 를 입력해 클립보드에 저장된 최근 복사 항목 목록을 표시합니다.",
+ "\uE77F", "#8B2FC9"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + D",
+ "다운로드 폴더 바로가기",
+ "사용자 홈의 Downloads 폴더 경로를 입력창에 채워 탐색기로 열 수 있게 합니다.",
+ "\uE8B7", "#107C10"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + F",
+ "파일 검색 모드로 전환",
+ "입력창을 초기화하고 포커스를 이동합니다. 이후 파일명을 바로 타이핑해 검색할 수 있습니다.",
+ "\uE71E", "#4B5EFC"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + W",
+ "런처 창 즉시 닫기",
+ "현재 입력 내용에 관계없이 런처를 즉시 숨깁니다.",
+ "\uE711", "#9999BB"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + K",
+ "단축키 참조 모달 창 열기",
+ "모든 단축키와 설명을 보여주는 별도 모달 창이 열립니다. Esc 또는 닫기 버튼으로 닫습니다.",
+ "\uE8FD", "#4B5EFC"));
+ items.Add(MakeShortcut("런처 기능", "Ctrl + 1 ~ 9",
+ "N번째 결과 항목 바로 실행",
+ "목록에 번호 배지(1~9)가 표시된 항목을 해당 숫자 키로 즉시 실행합니다. 마우스 없이 빠른 실행에 유용합니다.",
+ "\uE8C4", "#107C10"));
+
+ // ── 기타 창 단축키 ────────────────────────────────────────────────────
+ items.Add(MakeShortcut("기타 창", "← / →",
+ "헬프 창 카테고리 이동",
+ "이 도움말 창에서 하위 카테고리 탭을 왼쪽/오른쪽으로 이동합니다.",
+ "\uE76B", "#4455AA"));
+ items.Add(MakeShortcut("기타 창", "1 / 2 / 3",
+ "헬프 창 상단 메뉴 전환",
+ "이 도움말 창에서 개요(1), 단축키 현황(2), 예약어 현황(3)을 키보드로 전환합니다.",
+ "\uE8BD", "#4455AA"));
+ items.Add(MakeShortcut("기타 창", "방향키 (캡처 중)",
+ "영역 선택 경계 1px 미세 조정",
+ "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 1픽셀씩 정밀 조정합니다.",
+ "\uE745", "#BE185D"));
+ items.Add(MakeShortcut("기타 창", "Shift + 방향키 (캡처 중)",
+ "영역 선택 경계 10px 이동",
+ "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 10픽셀씩 빠르게 이동합니다.",
+ "\uE745", "#BE185D"));
+
+ return items;
+ }
+}
diff --git a/src/AxCopilot/Views/HelpDetailWindow.xaml.cs b/src/AxCopilot/Views/HelpDetailWindow.xaml.cs
index 6c87f69..a56434f 100644
--- a/src/AxCopilot/Views/HelpDetailWindow.xaml.cs
+++ b/src/AxCopilot/Views/HelpDetailWindow.xaml.cs
@@ -219,169 +219,7 @@ public partial class HelpDetailWindow : Window
KeyDown += OnKeyDown;
}
- // ─── 단축키 항목 빌드 ─────────────────────────────────────────────────────
-
- private static List BuildShortcutItems(string globalHotkey = "Alt+Space")
- {
- var items = new List();
-
- // 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "Alt+Space" → "Alt + Space")
- var hotkeyDisplay = globalHotkey.Replace("+", " + ");
-
- // ── 전역 단축키 ──────────────────────────────────────────────────────
- items.Add(MakeShortcut("전역", hotkeyDisplay,
- "AX Commander 열기/닫기",
- "어느 창에서든 눌러 AX Commander를 즉시 호출하거나 닫습니다. 설정 › 일반에서 원하는 키 조합으로 변경할 수 있습니다.",
- "\uE765", "#4B5EFC"));
- items.Add(MakeShortcut("전역", "PrintScreen",
- "화면 캡처 즉시 실행",
- "AX Commander를 열지 않고 곧바로 캡처를 시작합니다. 설정 › 캡처 탭에서 '글로벌 단축키 활성화'를 켜야 동작합니다.",
- "\uE722", "#BE185D"));
-
- // ── 런처 탐색 ────────────────────────────────────────────────────────
- items.Add(MakeShortcut("AX Commander 탐색", "Escape",
- "창 닫기 / 이전 단계로",
- "액션 모드(→ 로 진입)에 있을 때는 일반 검색 화면으로 돌아갑니다. 일반 화면이면 AX Commander를 숨깁니다.",
- "\uE711", "#999999"));
- items.Add(MakeShortcut("AX Commander 탐색", "Enter",
- "선택 항목 실행",
- "파일·앱이면 열기, URL이면 브라우저 열기, 시스템 명령이면 즉시 실행, 계산기 결과면 클립보드에 복사합니다.",
- "\uE768", "#107C10"));
- items.Add(MakeShortcut("AX Commander 탐색", "Shift + Enter",
- "대형 텍스트(Large Type) 표시 / 클립보드 병합 실행",
- "선택된 텍스트·검색어를 화면 전체에 크게 띄웁니다. 클립보드 병합 항목이 있을 때는 선택한 항목들을 줄바꿈으로 합쳐 클립보드에 복사합니다.",
- "\uE8C1", "#8764B8"));
- items.Add(MakeShortcut("AX Commander 탐색", "↑ / ↓",
- "결과 목록 위/아래 이동",
- "목록 끝에서 계속 누르면 처음/끝으로 순환합니다.",
- "\uE74A", "#0078D4"));
- items.Add(MakeShortcut("AX Commander 탐색", "PageUp / PageDown",
- "목록 5칸 빠른 이동",
- "한 번에 5항목씩 건너뜁니다. 빠른 목록 탐색에 유용합니다.",
- "\uE74A", "#0078D4"));
- items.Add(MakeShortcut("AX Commander 탐색", "Home / End",
- "목록 처음 / 마지막 항목으로 점프",
- "입력창 커서가 맨 앞(또는 입력이 없을 때)이면 첫 항목으로, 맨 끝이면 마지막 항목으로 선택이 이동합니다.",
- "\uE74A", "#0078D4"));
- items.Add(MakeShortcut("AX Commander 탐색", "→ (오른쪽 화살표)",
- "액션 모드 진입",
- "파일·앱 항목을 선택한 상태에서 → 를 누르면 경로 복사, 탐색기 열기, 관리자 실행, 터미널, 속성, 이름 변경, 삭제 메뉴가 나타납니다.",
- "\uE76C", "#44546A"));
- items.Add(MakeShortcut("AX Commander 탐색", "Tab",
- "선택 항목 제목으로 자동완성",
- "현재 선택된 항목의 이름을 입력창에 채웁니다. 이후 계속 타이핑하거나 Enter로 실행합니다.",
- "\uE748", "#006EAF"));
- items.Add(MakeShortcut("AX Commander 탐색", "Shift + ↑/↓",
- "클립보드 병합 선택",
- "클립보드 히스토리(# 모드) 에서 여러 항목을 이동하면서 선택/해제합니다. Shift+Enter로 선택한 항목들을 한 번에 붙여넣을 수 있습니다.",
- "\uE8C1", "#B7791F"));
-
- // ── 런처 기능 단축키 ─────────────────────────────────────────────────
- items.Add(MakeShortcut("런처 기능", "F1",
- "도움말 창 열기",
- "이 화면을 직접 엽니다. 'help' 를 입력하는 것과 동일합니다.",
- "\uE897", "#6B7280"));
- items.Add(MakeShortcut("런처 기능", "F2",
- "선택 파일 이름 변경",
- "파일·폴더 항목을 선택한 상태에서 누르면 rename [경로] 형태로 입력창에 채워지고 이름 변경 핸들러가 실행됩니다.",
- "\uE70F", "#6B2C91"));
- items.Add(MakeShortcut("런처 기능", "F5",
- "파일 인덱스 즉시 재구축",
- "백그라운드에서 파일·앱 인덱싱을 다시 실행합니다. 새 파일을 추가했거나 목록이 오래됐을 때 사용합니다.",
- "\uE72C", "#059669"));
- items.Add(MakeShortcut("런처 기능", "Delete",
- "최근 실행 목록에서 항목 제거",
- "recent 목록에 있는 항목을 제거합니다. 확인 다이얼로그가 표시되며 OK를 눌러야 실제로 제거됩니다.",
- "\uE74D", "#DC2626"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + ,",
- "설정 창 열기",
- "AX Copilot 설정 창을 엽니다. 런처가 자동으로 숨겨집니다.",
- "\uE713", "#44546A"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + L",
- "입력창 전체 초기화",
- "현재 입력된 검색어·예약어를 모두 지우고 커서를 빈 입력창으로 돌립니다.",
- "\uE894", "#4B5EFC"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + C",
- "선택 항목 파일 이름 복사",
- "파일·앱 항목이 선택된 경우 확장자를 제외한 파일 이름을 클립보드에 복사하고 토스트로 알립니다.",
- "\uE8C8", "#8764B8"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + C",
- "선택 항목 전체 경로 복사",
- "선택된 파일·폴더의 절대 경로(예: C:\\Users\\...)를 클립보드에 복사합니다.",
- "\uE8C8", "#C55A11"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + Shift + E",
- "파일 탐색기에서 선택 항목 열기",
- "Windows 탐색기가 열리고 해당 파일·폴더가 하이라이트 선택된 상태로 표시됩니다.",
- "\uE838", "#107C10"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + Enter",
- "관리자(UAC) 권한으로 실행",
- "선택된 파일·앱을 UAC 권한 상승 후 실행합니다. 설치 프로그램이나 시스템 설정 앱에 유용합니다.",
- "\uE7EF", "#C50F1F"));
- items.Add(MakeShortcut("런처 기능", "Alt + Enter",
- "파일 속성 대화 상자 열기",
- "Windows의 '파일 속성' 창(크기·날짜·권한 등)을 엽니다.",
- "\uE946", "#6B2C91"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + T",
- "선택 항목 위치에서 터미널 열기",
- "선택된 파일이면 해당 폴더에서, 폴더이면 그 경로에서 Windows Terminal(wt.exe)이 열립니다. wt가 없으면 cmd로 대체됩니다.",
- "\uE756", "#323130"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + P",
- "즐겨찾기 즉시 추가 / 제거 (핀)",
- "파일·폴더 항목을 선택한 상태에서 누르면 favorites.json 에 추가하거나 이미 있으면 제거합니다. 토스트로 결과를 알립니다.",
- "\uE734", "#D97706"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + B",
- "즐겨찾기 목록 보기 / 닫기 토글",
- "입력창이 'fav' 이면 초기화하고, 아니면 'fav' 를 입력해 즐겨찾기 목록을 표시합니다.",
- "\uE735", "#D97706"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + R",
- "최근 실행 목록 보기 / 닫기 토글",
- "'recent' 를 입력해 최근 실행 항목을 표시합니다.",
- "\uE81C", "#0078D4"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + H",
- "클립보드 히스토리 목록 열기",
- "'#' 를 입력해 클립보드에 저장된 최근 복사 항목 목록을 표시합니다.",
- "\uE77F", "#8B2FC9"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + D",
- "다운로드 폴더 바로가기",
- "사용자 홈의 Downloads 폴더 경로를 입력창에 채워 탐색기로 열 수 있게 합니다.",
- "\uE8B7", "#107C10"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + F",
- "파일 검색 모드로 전환",
- "입력창을 초기화하고 포커스를 이동합니다. 이후 파일명을 바로 타이핑해 검색할 수 있습니다.",
- "\uE71E", "#4B5EFC"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + W",
- "런처 창 즉시 닫기",
- "현재 입력 내용에 관계없이 런처를 즉시 숨깁니다.",
- "\uE711", "#9999BB"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + K",
- "단축키 참조 모달 창 열기",
- "모든 단축키와 설명을 보여주는 별도 모달 창이 열립니다. Esc 또는 닫기 버튼으로 닫습니다.",
- "\uE8FD", "#4B5EFC"));
- items.Add(MakeShortcut("런처 기능", "Ctrl + 1 ~ 9",
- "N번째 결과 항목 바로 실행",
- "목록에 번호 배지(1~9)가 표시된 항목을 해당 숫자 키로 즉시 실행합니다. 마우스 없이 빠른 실행에 유용합니다.",
- "\uE8C4", "#107C10"));
-
- // ── 기타 창 단축키 ────────────────────────────────────────────────────
- items.Add(MakeShortcut("기타 창", "← / →",
- "헬프 창 카테고리 이동",
- "이 도움말 창에서 하위 카테고리 탭을 왼쪽/오른쪽으로 이동합니다.",
- "\uE76B", "#4455AA"));
- items.Add(MakeShortcut("기타 창", "1 / 2 / 3",
- "헬프 창 상단 메뉴 전환",
- "이 도움말 창에서 개요(1), 단축키 현황(2), 예약어 현황(3)을 키보드로 전환합니다.",
- "\uE8BD", "#4455AA"));
- items.Add(MakeShortcut("기타 창", "방향키 (캡처 중)",
- "영역 선택 경계 1px 미세 조정",
- "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 1픽셀씩 정밀 조정합니다.",
- "\uE745", "#BE185D"));
- items.Add(MakeShortcut("기타 창", "Shift + 방향키 (캡처 중)",
- "영역 선택 경계 10px 이동",
- "화면 캡처의 영역 선택 모드에서 선택 영역 경계를 10픽셀씩 빠르게 이동합니다.",
- "\uE745", "#BE185D"));
-
- return items;
- }
+ // ─── 정적 헬퍼 ───────────────────────────────────────────────────────────
private static SolidColorBrush ParseColor(string hex)
{
@@ -401,263 +239,6 @@ public partial class HelpDetailWindow : Window
ColorBrush = ThemeResourceHelper.HexBrush(color)
};
}
-
- // ─── 상단 3탭 메뉴 빌드 ──────────────────────────────────────────────────
-
- // ─── 테마 색상 헬퍼 ───────────────────────────────────────────────────────
- private Brush ThemeAccent => TryFindResource("AccentColor") as Brush ?? ParseColor("#4B5EFC");
- private Brush ThemePrimary => TryFindResource("PrimaryText") as Brush ?? Brushes.White;
- private Brush ThemeSecondary => TryFindResource("SecondaryText") as Brush ?? ParseColor("#8899CC");
-
- private void BuildTopMenu()
- {
- TopMenuBar.Children.Clear();
- foreach (var (key, label, icon) in TopMenus)
- {
- var k = key;
- var sp = new StackPanel { Orientation = Orientation.Horizontal };
- sp.Children.Add(new TextBlock
- {
- Text = icon,
- FontFamily = ThemeResourceHelper.SegoeMdl2,
- FontSize = 14,
- Foreground = ThemeAccent,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 6, 0)
- });
- sp.Children.Add(new TextBlock
- {
- Text = label,
- FontSize = 12,
- VerticalAlignment = VerticalAlignment.Center
- });
-
- var btn = new Button
- {
- Content = sp,
- FontWeight = FontWeights.SemiBold,
- Padding = new Thickness(16, 8, 16, 8),
- Margin = new Thickness(4, 0, 4, 0),
- Cursor = Cursors.Hand,
- Background = Brushes.Transparent,
- Foreground = ThemeSecondary,
- BorderThickness = new Thickness(0, 0, 0, 2),
- BorderBrush = Brushes.Transparent,
- Tag = k,
- };
- btn.Click += (_, _) => SwitchTopMenu(k);
- TopMenuBar.Children.Add(btn);
- }
- }
-
- private void SwitchTopMenu(TopMenu menu)
- {
- _currentTopMenu = menu;
-
- // 상단 메뉴 하이라이트
- foreach (var child in TopMenuBar.Children)
- {
- if (child is Button btn)
- {
- var isActive = btn.Tag is TopMenu t && t == menu;
- btn.BorderBrush = isActive ? ThemeAccent : Brushes.Transparent;
- btn.Foreground = isActive ? ThemePrimary : ThemeSecondary;
-
- // 내부 TextBlock의 Foreground도 업데이트
- if (btn.Content is StackPanel sp)
- {
- foreach (var spChild in sp.Children)
- {
- if (spChild is TextBlock tb && tb.FontFamily.Source != "Segoe MDL2 Assets")
- tb.Foreground = btn.Foreground;
- }
- }
- }
- }
-
- // 하위 카테고리 탭 빌드
- switch (menu)
- {
- case TopMenu.Overview:
- BuildCategoryBarFor(_overviewItems);
- break;
- case TopMenu.Shortcuts:
- BuildCategoryBarFor(_shortcutItems);
- break;
- case TopMenu.Prefixes:
- BuildCategoryBarFor(_prefixItems);
- break;
- }
- }
-
- // ─── 하위 카테고리 탭 빌드 ────────────────────────────────────────────────
-
- private void BuildCategoryBarFor(List sourceItems)
- {
- var seen = new HashSet();
- _categories = new List { CatAll };
-
- // 예약어 탭에서만 인기 카테고리 표시
- if (_currentTopMenu == TopMenu.Prefixes)
- _categories.Add(CatPopular);
-
- foreach (var item in sourceItems)
- {
- if (seen.Add(item.Category))
- _categories.Add(item.Category);
- }
-
- BuildCategoryBar();
- NavigateToPage(0);
- }
-
- private void BuildCategoryBar()
- {
- CategoryBar.Children.Clear();
- for (int i = 0; i < _categories.Count; i++)
- {
- var cat = _categories[i];
- var idx = i;
-
- var btn = new Button
- {
- Content = cat,
- FontSize = 10.5,
- FontWeight = FontWeights.SemiBold,
- Padding = new Thickness(9, 4, 9, 4),
- Margin = new Thickness(0, 0, 3, 0),
- Cursor = Cursors.Hand,
- Background = Brushes.Transparent,
- Foreground = ThemeSecondary,
- BorderThickness = new Thickness(0),
- Tag = idx,
- };
-
- btn.Click += (_, _) => NavigateToPage(idx);
- CategoryBar.Children.Add(btn);
- }
- }
-
- // ─── 페이지 네비게이션 ──────────────────────────────────────────────────
-
- private List GetCurrentSourceItems() => _currentTopMenu switch
- {
- TopMenu.Overview => _overviewItems,
- TopMenu.Shortcuts => _shortcutItems,
- TopMenu.Prefixes => _prefixItems,
- _ => _overviewItems,
- };
-
- private void NavigateToPage(int pageIndex)
- {
- if (_categories.Count == 0) return;
- _currentPage = Math.Clamp(pageIndex, 0, _categories.Count - 1);
-
- var source = GetCurrentSourceItems();
- var cat = _categories[_currentPage];
- List filtered;
-
- if (cat == CatAll)
- filtered = source;
- else if (cat == CatPopular)
- {
- var popularCmds = new HashSet(StringComparer.OrdinalIgnoreCase)
- { "파일/폴더", "?", "#", "!", "clip", "pipe", "diff", "win" };
- filtered = source.Where(i =>
- popularCmds.Contains(i.Command) ||
- i.Command.StartsWith("?") ||
- i.Title.Contains("검색") ||
- i.Title.Contains("클립보드") ||
- (i.Title.Contains("파일") && i.Category != "키보드")
- ).ToList();
- }
- else
- filtered = source.Where(i => i.Category == cat).ToList();
-
- ItemsHost.ItemsSource = filtered;
-
- // 탭 하이라이트
- for (int i = 0; i < CategoryBar.Children.Count; i++)
- {
- if (CategoryBar.Children[i] is Button btn)
- {
- if (i == _currentPage)
- {
- btn.Background = ThemeAccent;
- btn.Foreground = Brushes.White;
- }
- else
- {
- btn.Background = Brushes.Transparent;
- btn.Foreground = ThemeSecondary;
- }
- }
- }
-
- PageIndicator.Text = $"{cat} ({filtered.Count}개)";
- PrevBtn.Visibility = _currentPage > 0 ? Visibility.Visible : Visibility.Hidden;
- NextBtn.Visibility = _currentPage < _categories.Count - 1 ? Visibility.Visible : Visibility.Hidden;
- }
-
- // ─── 이벤트 ────────────────────────────────────────────────────────────
-
- private void OnKeyDown(object sender, KeyEventArgs e)
- {
- switch (e.Key)
- {
- case Key.Escape:
- Close();
- break;
- case Key.Left:
- if (_currentPage > 0) NavigateToPage(_currentPage - 1);
- e.Handled = true;
- break;
- case Key.Right:
- if (_currentPage < _categories.Count - 1) NavigateToPage(_currentPage + 1);
- e.Handled = true;
- break;
- // 1, 2, 3 숫자키로 상단 메뉴 전환
- case Key.D1: SwitchTopMenu(TopMenu.Overview); e.Handled = true; break;
- case Key.D2: SwitchTopMenu(TopMenu.Shortcuts); e.Handled = true; break;
- case Key.D3: SwitchTopMenu(TopMenu.Prefixes); e.Handled = true; break;
- }
- }
-
- private void Prev_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage - 1);
- private void Next_Click(object sender, RoutedEventArgs e) => NavigateToPage(_currentPage + 1);
- private void Close_Click(object sender, RoutedEventArgs e) => Close();
-
- private void SearchBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
- {
- var query = SearchBox.Text.Trim();
- if (string.IsNullOrEmpty(query))
- {
- NavigateToPage(_currentPage);
- return;
- }
-
- var source = GetCurrentSourceItems();
- var filtered = source.Where(i =>
- i.Title.Contains(query, StringComparison.OrdinalIgnoreCase) ||
- i.Command.Contains(query, StringComparison.OrdinalIgnoreCase) ||
- i.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
- i.Category.Contains(query, StringComparison.OrdinalIgnoreCase)
- ).ToList();
-
- ItemsHost.ItemsSource = filtered;
- PageIndicator.Text = $"검색: \"{query}\" ({filtered.Count}개)";
- }
-
- private void Window_MouseDown(object sender, MouseButtonEventArgs e)
- {
- if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed)
- try { DragMove(); } catch (Exception) { }
- }
-
- private void Window_KeyDown(object sender, KeyEventArgs e)
- {
- if (e.Key == Key.Escape) Close();
- }
}
public class HelpItemModel