프로젝트 .claude/skills 재귀 로드와 namespaced SKILL.md 파싱을 추가하고 번들/사용자/프로젝트 스킬을 함께 노출하도록 SkillService와 설정 UI를 확장했다. 슬래시 스킬 호출 시 인자 치환, 스킬 폴더 변수 치환, inline shell 실행, when_to_use 기반 자동 스킬 가이드를 실제 ChatWindow 런타임 경로에 연결했다. blanket deny 권한은 모델 노출 전 활성 도구 목록에서 먼저 제외하도록 AgentLoopService를 보강했고 관련 테스트와 README/DEVELOPMENT 문서를 업데이트했다. 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_phase2\\ -p:IntermediateOutputPath=obj\\verify_phase2\\ (경고 0 / 오류 0) 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentToolCatalogTests|SkillServiceRuntimePolicyTests" -p:OutputPath=bin\\verify_phase2_tests\\ -p:IntermediateOutputPath=obj\\verify_phase2_tests\\ (통과 16, 기존 WorkspaceContextGeneratorTests nullable 경고 1건 유지)
670 lines
26 KiB
C#
670 lines
26 KiB
C#
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<string, bool>(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<string, int> BuildRecentSlashRankMap()
|
|
{
|
|
var map = new Dictionary<string, int>(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<string, int> BuildFavoriteSlashRankMap()
|
|
{
|
|
var map = new Dictionary<string, int>(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<int> GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder;
|
|
|
|
/// <summary>현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>슬래시 팝업 마우스 휠 스크롤 처리.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>슬래시 팝업을 Delta 방향으로 스크롤합니다.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>키보드로 선택된 슬래시 아이템을 실행합니다.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>슬래시 명령어 칩을 표시하고 InputBox를 비웁니다.</summary>
|
|
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 = "";
|
|
}
|
|
|
|
/// <summary>슬래시 명령어 칩을 숨깁니다.</summary>
|
|
/// <param name="restoreText">true이면 InputBox에 명령어 텍스트를 복원합니다.</param>
|
|
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;
|
|
}
|
|
}
|
|
}
|