AX Agent 코워크·코드 흐름과 컨텍스트 관리를 claude-code 기준으로 대폭 정리
- 코워크·코드 프롬프트, 도구 선택, 문서 생성/검증 흐름을 claude-code 동등 품질 기준으로 재정렬함 - OpenAI/vLLM 경로의 오래된 tool history를 평탄화하고 최근 이력만 구조화해 컨텍스트 직렬화를 경량화함 - AX Agent UI를 테마 기준으로 재구성하고 플랜 승인/오버레이/이벤트 렌더링/명령 입력 상호작용을 개선함 - 파일 후보 제안, 반복 경로 정체 복구, LSP 보강, 문서·PPT 처리 개선, 설정/서비스 인터페이스 정리를 함께 반영함 - README.md 및 docs/DEVELOPMENT.md를 작업 시점별로 갱신함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
This commit is contained in:
667
src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs
Normal file
667
src/AxCopilot/Views/ChatWindow.SlashCommandPresentation.cs
Normal file
@@ -0,0 +1,667 @@
|
||||
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}";
|
||||
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;
|
||||
try { _settings.Save(); } catch { }
|
||||
}
|
||||
|
||||
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();
|
||||
try { _settings.Save(); } catch { }
|
||||
_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);
|
||||
try { _settings.Save(); } catch { }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_settings.Save();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user