using System; using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; namespace AxCopilot.Views; public partial class ChatWindow { private static string BuildSlashSkillLabel(SkillDefinition skill) { var badge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase) ? "[FORK]" : "[DIRECT]"; var baseLabel = $"{badge} {skill.Label}"; if (!string.IsNullOrWhiteSpace(skill.ArgumentHint)) baseLabel = $"{baseLabel} {skill.ArgumentHint.Trim()}"; return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}"; } private bool GetSlashSectionExpanded(string sectionKey, bool defaultValue = true) { var map = _settings.Settings.Llm.SlashPaletteSections; if (map != null && map.TryGetValue(sectionKey, out var expanded)) return expanded; return defaultValue; } private void SetSlashSectionExpanded(string sectionKey, bool expanded) { var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary(StringComparer.OrdinalIgnoreCase); map[sectionKey] = expanded; ScheduleSettingsSave(); } private bool AreAllSlashSectionsExpanded() { var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); return commandsExpanded && skillsExpanded; } private void BtnSlashToggleGroups_Click(object sender, RoutedEventArgs e) { var expandAll = !AreAllSlashSectionsExpanded(); SetSlashSectionExpanded("slash_commands", expandAll); SetSlashSectionExpanded("slash_skills", expandAll); _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); RenderSlashPage(); } private void BtnSlashReset_Click(object sender, RoutedEventArgs e) { _settings.Settings.Llm.FavoriteSlashCommands.Clear(); _settings.Settings.Llm.RecentSlashCommands.Clear(); ScheduleSettingsSave(); _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); RenderSlashPage(); } private Dictionary BuildRecentSlashRankMap() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var recent = _settings.Settings.Llm.RecentSlashCommands; for (var i = 0; i < recent.Count; i++) { var key = recent[i]?.Trim(); if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) continue; map[key] = i; // index 낮을수록 최근 } return map; } private Dictionary BuildFavoriteSlashRankMap() { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); var fav = _settings.Settings.Llm.FavoriteSlashCommands; var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); for (var i = 0; i < fav.Count; i++) { if (i >= maxFavorites) break; var key = fav[i]?.Trim(); if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key)) continue; map[key] = i; // index 낮을수록 우선 } return map; } private void RegisterRecentSlashCommand(string cmd) { if (string.IsNullOrWhiteSpace(cmd)) return; var recent = _settings.Settings.Llm.RecentSlashCommands; var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentSlashCommands, 5, 50); recent.RemoveAll(x => string.Equals(x, cmd, StringComparison.OrdinalIgnoreCase)); recent.Insert(0, cmd); if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent); ScheduleSettingsSave(); } private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches) { var commandExpanded = GetSlashSectionExpanded("slash_commands", true); var skillExpanded = GetSlashSectionExpanded("slash_skills", true); for (var i = 0; i < matches.Count; i++) { var visible = matches[i].IsSkill ? skillExpanded : commandExpanded; if (visible) return i; } return -1; } private bool IsSlashItemVisibleByIndex(int index) { if (index < 0 || index >= _slashPalette.Matches.Count) return false; var item = _slashPalette.Matches[index]; return item.IsSkill ? GetSlashSectionExpanded("slash_skills", true) : GetSlashSectionExpanded("slash_commands", true); } private IReadOnlyList GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder; /// 현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다. private void RenderSlashPage() { SlashItems.Items.Clear(); _slashVisibleItemByAbsoluteIndex.Clear(); _slashVisibleAbsoluteOrder.Clear(); var total = _slashPalette.Matches.Count; var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill); var totalCommands = total - totalSkills; var favoriteRank = BuildFavoriteSlashRankMap(); var recentRank = BuildRecentSlashRankMap(); var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); SlashPopupTitle.Text = "명령 및 스킬"; SlashPopupHint.Text = expressionLevel switch { "simple" => $"명령 {totalCommands} · 스킬 {totalSkills}", "rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행 · 방향키 이동", _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행", }; var commandsExpanded = GetSlashSectionExpanded("slash_commands", true); var skillsExpanded = GetSlashSectionExpanded("slash_skills", true); if (SlashToggleGroupsLabel != null) SlashToggleGroupsLabel.Text = (commandsExpanded && skillsExpanded) ? "전체 접기" : "전체 펼치기"; Border CreateSlashSectionHeader(string key, string title, int count, bool expanded) { var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; var header = new Border { Background = Brushes.Transparent, BorderBrush = Brushes.Transparent, BorderThickness = new Thickness(0), CornerRadius = new CornerRadius(8), Padding = new Thickness(8, 6, 8, 6), Margin = new Thickness(0, 4, 0, 2), Cursor = Cursors.Hand, }; var grid = new Grid(); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); grid.Children.Add(new TextBlock { Text = expanded ? "\uE70D" : "\uE76C", FontFamily = s_segoeIconFont, FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 6, 0), VerticalAlignment = VerticalAlignment.Center, }); var titleText = new TextBlock { Text = $"{title} {count}", FontSize = 10.5, FontWeight = FontWeights.SemiBold, Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(titleText, 1); grid.Children.Add(titleText); var metaText = new TextBlock { Text = expanded ? "접기" : "펼치기", FontSize = 9.5, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, }; Grid.SetColumn(metaText, 2); grid.Children.Add(metaText); header.Child = grid; header.MouseEnter += (_, _) => header.Background = hoverBrushItem; header.MouseLeave += (_, _) => header.Background = Brushes.Transparent; header.MouseLeftButtonDown += (_, _) => { SetSlashSectionExpanded(key, !expanded); _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches); RenderSlashPage(); }; return header; } void AddSlashItem(int i) { var (cmd, label, isSkill) = _slashPalette.Matches[i]; var isFavorite = favoriteRank.ContainsKey(cmd); var isRecent = recentRank.ContainsKey(cmd); var capturedCmd = cmd; var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; var skillAvailable = skillDef?.IsAvailable ?? true; var absoluteIndex = i; var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; var item = new Border { Background = Brushes.Transparent, BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, BorderThickness = new Thickness(0, 0, 0, 1), CornerRadius = new CornerRadius(0), Padding = new Thickness(8, 9, 8, 9), Margin = new Thickness(0, 0, 0, 0), Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow, Opacity = skillAvailable ? 1.0 : 0.5, }; var itemGrid = new Grid(); itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var leftStack = new StackPanel(); var titleRow = new StackPanel { Orientation = Orientation.Horizontal }; titleRow.Children.Add(new TextBlock { Text = isSkill ? "\uE768" : "\uE9CE", FontFamily = s_segoeIconFont, FontSize = 11, Foreground = skillAvailable ? accent : secondaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0), }); titleRow.Children.Add(new TextBlock { Text = cmd, FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = skillAvailable ? primaryText : secondaryText, VerticalAlignment = VerticalAlignment.Center, }); if (isFavorite) { titleRow.Children.Add(new Border { Background = BrushFromHex("#FEF3C7"), BorderBrush = BrushFromHex("#F59E0B"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(5, 0, 5, 0), Margin = new Thickness(6, 0, 0, 0), Child = new TextBlock { Text = "핀", FontSize = 9.5, Foreground = BrushFromHex("#92400E"), } }); } if (isRecent) { titleRow.Children.Add(new Border { Background = BrushFromHex("#EEF2FF"), BorderBrush = BrushFromHex("#C7D2FE"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(8), Padding = new Thickness(5, 0, 5, 0), Margin = new Thickness(6, 0, 0, 0), Child = new TextBlock { Text = "최근", FontSize = 9.5, Foreground = BrushFromHex("#3730A3"), } }); } leftStack.Children.Add(titleRow); leftStack.Children.Add(new TextBlock { Text = label, FontSize = 11, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(20, 2, 0, 0), TextTrimming = TextTrimming.CharacterEllipsis, }); Grid.SetColumn(leftStack, 0); itemGrid.Children.Add(leftStack); var pinToggle = new Border { Background = Brushes.Transparent, CornerRadius = new CornerRadius(6), Padding = new Thickness(6, 4, 6, 4), Margin = new Thickness(6, 0, 0, 0), Cursor = Cursors.Hand, Child = new TextBlock { Text = isFavorite ? "\uE77A" : "\uE718", FontFamily = s_segoeIconFont, FontSize = 11, Foreground = isFavorite ? BrushFromHex("#B45309") : secondaryText, VerticalAlignment = VerticalAlignment.Center, }, ToolTip = isFavorite ? "핀 해제" : "핀 고정", }; pinToggle.MouseEnter += (_, _) => pinToggle.Background = hoverBrushItem; pinToggle.MouseLeave += (_, _) => pinToggle.Background = Brushes.Transparent; pinToggle.MouseLeftButtonDown += (s, e) => { e.Handled = true; ToggleSlashFavorite(capturedCmd); }; Grid.SetColumn(pinToggle, 1); itemGrid.Children.Add(pinToggle); item.Child = itemGrid; if (skillAvailable) { item.MouseEnter += (_, _) => { _slashPalette.SelectedIndex = absoluteIndex; UpdateSlashSelectionVisualState(); }; item.MouseLeave += (_, _) => UpdateSlashSelectionVisualState(); item.MouseLeftButtonDown += (_, _) => { _slashPalette.SelectedIndex = absoluteIndex; ExecuteSlashSelectedItem(); }; } SlashItems.Items.Add(item); _slashVisibleItemByAbsoluteIndex[absoluteIndex] = item; _slashVisibleAbsoluteOrder.Add(absoluteIndex); } SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded)); if (commandsExpanded) { var commandIndices = Enumerable.Range(0, total) .Where(i => !_slashPalette.Matches[i].IsSkill) .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); foreach (var i in commandIndices) { AddSlashItem(i); } } SlashItems.Items.Add(CreateSlashSectionHeader("slash_skills", "스킬", totalSkills, skillsExpanded)); if (skillsExpanded) { var skillIndices = Enumerable.Range(0, total) .Where(i => _slashPalette.Matches[i].IsSkill) .OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue) .ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue) .ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase); foreach (var i in skillIndices) { AddSlashItem(i); } } var visibleCommandCount = commandsExpanded ? totalCommands : 0; var visibleSkillCount = skillsExpanded ? totalSkills : 0; if (visibleCommandCount + visibleSkillCount == 0) { SlashPopupFooter.Text = "모든 그룹이 접혀 있습니다 · 우측 상단에서 전체 펼치기"; } else { SlashPopupFooter.Text = $"Enter 실행 · ↑↓/PgUp/PgDn 이동 · Home/End · Esc 닫기 · 표시 {visibleCommandCount + visibleSkillCount}/{total}"; } UpdateSlashSelectionVisualState(); EnsureSlashSelectionVisible(); } /// 슬래시 팝업 마우스 휠 스크롤 처리. private void SlashPopup_PreviewMouseWheel(object sender, MouseWheelEventArgs e) { e.Handled = true; SlashPopup_ScrollByDelta(e.Delta); } private void MoveSlashSelection(int direction) { var visibleOrder = GetVisibleSlashOrderedIndices(); if (visibleOrder.Count == 0) return; var currentPosition = -1; for (var i = 0; i < visibleOrder.Count; i++) { if (visibleOrder[i] != _slashPalette.SelectedIndex) continue; currentPosition = i; break; } if (currentPosition < 0) { _slashPalette.SelectedIndex = visibleOrder[0]; return; } if (direction < 0 && currentPosition > 0) _slashPalette.SelectedIndex = visibleOrder[currentPosition - 1]; else if (direction > 0 && currentPosition < visibleOrder.Count - 1) _slashPalette.SelectedIndex = visibleOrder[currentPosition + 1]; } private int? FindSlashIndexClosestToViewportTop() { if (SlashScrollViewer == null || _slashVisibleAbsoluteOrder.Count == 0) return null; var bestIndex = -1; var bestDistance = double.MaxValue; foreach (var absoluteIndex in _slashVisibleAbsoluteOrder) { if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(absoluteIndex, out var item)) continue; try { var bounds = item.TransformToAncestor(SlashScrollViewer) .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); // 뷰포트 상단에 가장 가까운 가시 항목을 선택 기준으로 사용. var distance = Math.Abs(bounds.Top); if (distance < bestDistance && bounds.Bottom >= 0) { bestDistance = distance; bestIndex = absoluteIndex; } } catch { // 레이아웃 갱신 중 transform 예외는 무시. } } return bestIndex >= 0 ? bestIndex : null; } /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. private void SlashPopup_ScrollByDelta(int delta) { if (_slashPalette.Matches.Count == 0) return; if (GetVisibleSlashOrderedIndices().Count == 0) { if (SlashScrollViewer != null) SlashScrollViewer.ScrollToVerticalOffset(Math.Max(0, SlashScrollViewer.VerticalOffset - delta / 3.0)); return; } // 터치패드/마우스 환경 모두에서 체감이 유사하도록 스크롤뷰도 함께 이동. if (SlashScrollViewer != null) { var target = Math.Max(0, Math.Min( SlashScrollViewer.ScrollableHeight, SlashScrollViewer.VerticalOffset - (delta / 3.0))); SlashScrollViewer.ScrollToVerticalOffset(target); } var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(delta) / 120.0)); var direction = delta > 0 ? -1 : 1; for (var i = 0; i < steps; i++) MoveSlashSelection(direction); var viewportTopIndex = FindSlashIndexClosestToViewportTop(); if (viewportTopIndex.HasValue) _slashPalette.SelectedIndex = viewportTopIndex.Value; UpdateSlashSelectionVisualState(); EnsureSlashSelectionVisible(); } /// 키보드로 선택된 슬래시 아이템을 실행합니다. private void ExecuteSlashSelectedItem() { var absoluteIdx = _slashPalette.SelectedIndex; if (absoluteIdx < 0 || absoluteIdx >= _slashPalette.Matches.Count) return; var (cmd, _, isSkill) = _slashPalette.Matches[absoluteIdx]; var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; var skillAvailable = skillDef?.IsAvailable ?? true; if (!skillAvailable) return; RegisterRecentSlashCommand(cmd); SlashPopup.IsOpen = false; _slashPalette.SelectedIndex = -1; if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase)) { InputBox.Text = ""; ShowSlashHelpWindow(); return; } ShowSlashChip(cmd); InputBox.Focus(); } private void EnsureSlashSelectionVisible() { if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0) return; if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(_slashPalette.SelectedIndex, out var item)) return; if (!IsVisualDescendantOf(item, SlashScrollViewer)) return; Rect bounds; try { bounds = item.TransformToAncestor(SlashScrollViewer) .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); } catch { // 렌더 트리 갱신 중에는 transform이 실패할 수 있어 조용히 무시. return; } if (bounds.Top < 0) SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + bounds.Top - 8); else if (bounds.Bottom > SlashScrollViewer.ViewportHeight) SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + (bounds.Bottom - SlashScrollViewer.ViewportHeight) + 8); } private static bool IsVisualDescendantOf(DependencyObject? child, DependencyObject? parent) { if (child == null || parent == null) return false; var current = child; while (current != null) { if (ReferenceEquals(current, parent)) return true; current = VisualTreeHelper.GetParent(current); } return false; } private void UpdateSlashSelectionVisualState() { if (_slashVisibleItemByAbsoluteIndex.Count == 0) return; var selectedIndex = _slashPalette.SelectedIndex; var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; foreach (var (absoluteIndex, element) in _slashVisibleItemByAbsoluteIndex) { if (element is not Border border) continue; var selected = absoluteIndex == selectedIndex; border.Background = selected ? hoverBrushItem : Brushes.Transparent; border.BorderBrush = selected ? accent : borderBrush; border.BorderThickness = selected ? new Thickness(2, 0, 0, 1) : new Thickness(0, 0, 0, 1); } } /// 슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다. private void ToggleSlashFavorite(string cmd) { var favs = _settings.Settings.Llm.FavoriteSlashCommands; var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30); var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase)); if (existing != null) favs.Remove(existing); else { favs.Add(cmd); if (favs.Count > maxFavorites) favs.RemoveRange(maxFavorites, favs.Count - maxFavorites); } ScheduleSettingsSave(); if (SlashPopup.IsOpen) { RenderSlashPage(); return; } // 팝업이 닫힌 경우에만 TextChanged 트리거 var currentText = InputBox.Text; InputBox.TextChanged -= InputBox_TextChanged; InputBox.Text = ""; InputBox.TextChanged += InputBox_TextChanged; InputBox.Text = currentText; } /// 슬래시 명령어 칩을 표시하고 InputBox를 비웁니다. private void ShowSlashChip(string cmd) { _slashPalette.ActiveCommand = cmd; SlashChipText.Text = cmd; SlashCommandChip.Visibility = Visibility.Visible; // 칩 너비 측정 후 InputBox 왼쪽 여백 조정 SlashCommandChip.UpdateLayout(); var chipRight = SlashCommandChip.Margin.Left + SlashCommandChip.ActualWidth + 6; InputBox.Padding = new Thickness(chipRight, 10, 14, 10); InputBox.Text = ""; } /// 슬래시 명령어 칩을 숨깁니다. /// true이면 InputBox에 명령어 텍스트를 복원합니다. private void HideSlashChip(bool restoreText = false) { if (_slashPalette.ActiveCommand == null) return; var prev = _slashPalette.ActiveCommand; _slashPalette.ActiveCommand = null; SlashCommandChip.Visibility = Visibility.Collapsed; InputBox.Padding = new Thickness(14, 10, 14, 10); if (restoreText) { InputBox.Text = prev + " "; InputBox.CaretIndex = InputBox.Text.Length; } } }