Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,310 @@
<Window x:Class="AxCopilot.Views.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Copilot — 정보"
Width="460" SizeToContent="Height"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
ResizeMode="NoResize"
WindowStartupLocation="CenterScreen"
ShowInTaskbar="False"
Topmost="True"
MouseDown="Window_MouseDown">
<Window.Resources>
<Style x:Key="LinkButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<TextBlock x:Name="Tb"
Text="{TemplateBinding Content}"
FontSize="12"
Foreground="#7B8FFF"
TextDecorations="Underline"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Tb" Property="Foreground" Value="#4B5EFC"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Margin="20">
<Border CornerRadius="20" Background="White">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="22" ShadowDepth="4" Opacity="0.24"/>
</Border.Effect>
</Border>
<Border Background="White" CornerRadius="20" ClipToBounds="True">
<Grid>
<Grid.RowDefinitions>
<!-- 상단 헤더 그라데이션 -->
<RowDefinition Height="210"/>
<!-- 본문 -->
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ══ 헤더 ══ -->
<Grid Grid.Row="0">
<Grid.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1A1B2E" Offset="0"/>
<GradientStop Color="#2B2D5B" Offset="0.5"/>
<GradientStop Color="#3B4ECC" Offset="1"/>
</LinearGradientBrush>
</Grid.Background>
<!-- 닫기 버튼 -->
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,14,16,0"
Width="28" Height="28"
Background="Transparent" BorderThickness="0"
Cursor="Hand" Click="Close_Click">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="14" Background="Transparent">
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#88AAFFEE"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#22FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<!-- 앱 아이콘 (항상 표시) -->
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Margin="0,10,0,0">
<Border Width="88" Height="88" CornerRadius="44"
HorizontalAlignment="Center"
BorderBrush="#33FFFFFF" BorderThickness="2">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#2E3060" Offset="0"/>
<GradientStop Color="#3B4ECC" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid>
<!-- 다이아몬드 픽셀 아이콘 (벡터, 해상도 독립) -->
<Canvas x:Name="DiamondIconCanvas"
Width="48" Height="48"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Canvas.RenderTransform>
<RotateTransform Angle="45" CenterX="24" CenterY="24"/>
</Canvas.RenderTransform>
<!-- 좌상→상: Blue -->
<Rectangle Canvas.Left="1" Canvas.Top="1"
Width="21" Height="21"
RadiusX="3" RadiusY="3"
Fill="#4488FF"/>
<!-- 우상→우: Green -->
<Rectangle Canvas.Left="26" Canvas.Top="1"
Width="21" Height="21"
RadiusX="3" RadiusY="3"
Fill="#44DD66"/>
<!-- 좌하→좌: Green -->
<Rectangle Canvas.Left="1" Canvas.Top="26"
Width="21" Height="21"
RadiusX="3" RadiusY="3"
Fill="#44DD66"/>
<!-- 우하→하: Red -->
<Rectangle Canvas.Left="26" Canvas.Top="26"
Width="21" Height="21"
RadiusX="3" RadiusY="3"
Fill="#FF4466"/>
</Canvas>
<!-- 비트맵 아이콘 폴백 (숨김) -->
<Image x:Name="AppIconImage" Visibility="Collapsed"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock x:Name="FallbackIcon" Visibility="Collapsed"
Text="&#xE700;"
FontFamily="Segoe MDL2 Assets"
FontSize="38" Foreground="#88FFFFFF"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- 마스코트 이미지 (숨겨진 상태 — 사용자 클릭 시 오버레이용으로만 사용) -->
<Image x:Name="MascotImage" Visibility="Collapsed"/>
<TextBlock x:Name="AppNameText"
Text="AX Copilot"
FontSize="20" FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"
Margin="0,14,0,3"/>
<TextBlock x:Name="VersionText"
Text="v1.0.0"
FontSize="12" Foreground="#88AACCFF"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- ══ 마스코트 오버레이 (클릭 시 표시) ══ -->
<Grid x:Name="MascotOverlay"
Grid.Row="0" Grid.RowSpan="2"
Visibility="Collapsed"
Panel.ZIndex="10">
<!-- 어두운 배경 (클릭 시 닫기) -->
<Rectangle Fill="#CC101025"
MouseDown="HideMascot_Click"/>
<!-- 마스코트 이미지 -->
<Border Width="240" Height="240"
CornerRadius="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ClipToBounds="True"
BorderBrush="#33FFFFFF"
BorderThickness="1.5">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="40"
ShadowDepth="0" Opacity="0.7"/>
</Border.Effect>
<Image x:Name="MascotOverlayImage"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"/>
</Border>
<!-- 닫기 힌트 -->
<TextBlock Text="클릭하여 닫기"
FontSize="11" Foreground="#88AACCFF"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="0,270,0,0"
IsHitTestVisible="False"/>
</Grid>
<!-- ══ 본문 ══ -->
<StackPanel Grid.Row="1" Margin="32,24,32,20">
<!-- 구분선 -->
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,20"/>
<!-- 개발 목적 -->
<Border Background="#F7F8FF" CornerRadius="12" Padding="16,13" Margin="0,0,0,20">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE946;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="#4B5EFC"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="개발 목적"
FontSize="11" FontWeight="SemiBold"
Foreground="#4B5EFC"/>
</StackPanel>
<TextBlock x:Name="PurposeText"
Text="업무 편의성 증가 및 시스템과 직관적인 연결을 위해 제작"
FontSize="12" Foreground="#444466"
TextWrapping="Wrap" LineHeight="18"/>
</StackPanel>
</Border>
<!-- 개발자 정보 -->
<StackPanel Margin="0,0,0,16">
<TextBlock Text="개발자 정보"
FontSize="10.5" FontWeight="SemiBold"
Foreground="#AAAACC" Margin="0,0,0,10"/>
<!-- 조직 (클릭 시 마스코트 팝업) -->
<Grid Margin="0,0,0,10" Cursor="Hand" Background="Transparent"
MouseDown="ShowMascot_Click">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Width="24" Height="24" CornerRadius="6"
Background="#EEF0FF" VerticalAlignment="Center">
<TextBlock Text="&#xE7EE;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="#4B5EFC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock x:Name="CompanyNameText"
Text="AX연구소 AI팀"
FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E"/>
<TextBlock Text="백승재 · SW Architect"
FontSize="11" Foreground="#7777AA" Margin="0,2,0,0"/>
</StackPanel>
<!-- 클릭 힌트 아이콘 -->
<TextBlock Grid.Column="2" Text="&#xE76C;"
FontFamily="Segoe MDL2 Assets" FontSize="9"
Foreground="#CCCCDD" VerticalAlignment="Center"
Margin="0,0,2,0"/>
</Grid>
<!-- 블로그 -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Width="24" Height="24" CornerRadius="6"
Background="#EEF0FF" VerticalAlignment="Center">
<TextBlock Text="&#xE774;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="#4B5EFC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Text="블로그" FontSize="11" Foreground="#9999BB" Margin="0,0,0,2"/>
<Button x:Name="BlogLinkBtn"
Content="www.swarchitect.net"
Style="{StaticResource LinkButton}"
Click="Blog_Click"/>
</StackPanel>
</Grid>
<!-- 기여자 -->
<Grid Margin="0,12,0,0" x:Name="ContributorsGrid" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Width="24" Height="24" CornerRadius="6"
Background="#EEF0FF" VerticalAlignment="Center">
<TextBlock Text="&#xE716;" FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="#4B5EFC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Text="기여자" FontSize="11" Foreground="#9999BB" Margin="0,0,0,2"/>
<TextBlock x:Name="ContributorsText"
FontSize="12" Foreground="#444466"
TextWrapping="Wrap" LineHeight="18"/>
</StackPanel>
</Grid>
</StackPanel>
<!-- 구분선 -->
<Rectangle Height="1" Fill="#F0F0F8" Margin="0,0,0,14"/>
<!-- 버전/빌드 정보 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBlock Text="&#xE700;" FontFamily="Segoe MDL2 Assets"
FontSize="10" Foreground="#CCCCDD"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock x:Name="BuildInfoText"
FontSize="10.5" Foreground="#BBBBCC"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,195 @@
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
namespace AxCopilot.Views;
public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();
Loaded += OnLoaded;
KeyDown += (_, e) => { if (e.Key == Key.Escape) Close(); };
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
// 버전 정보
var version = Assembly.GetExecutingAssembly().GetName().Version;
VersionText.Text = version != null ? $"v{version.Major}.{version.Minor}.{version.Build}" : "v1.0";
BuildInfoText.Text = $"AX Copilot \u00b7 Commander + Agent \u00b7 \u00a9 2026";
// about.json에서 모든 텍스트 로드 (없으면 기본값 유지)
TryLoadBranding(version);
// 상단: 앱 아이콘 로드
TryLoadAppIcon();
// 마스코트: 숨겨진 상태로 로드 (사용자 클릭 시 오버레이용)
TryLoadMascot();
}
private void TryLoadBranding(Version? version)
{
try
{
var uri = new Uri("pack://application:,,,/Assets/about.json");
var info = System.Windows.Application.GetResourceStream(uri);
if (info == null) return;
using var reader = new StreamReader(info.Stream);
using var doc = JsonDocument.Parse(reader.ReadToEnd());
var root = doc.RootElement;
string Get(string key) =>
root.TryGetProperty(key, out var v) ? v.GetString() ?? "" : "";
var appName = Get("appName");
var company = Get("companyName");
var author = Get("authorName");
var title = Get("authorTitle");
var purpose = Get("purpose");
var copyright = Get("copyright");
var blogUrl = Get("blogUrl");
// 앱 이름 (헤더)
if (!string.IsNullOrWhiteSpace(appName))
AppNameText.Text = appName;
// 회사명 (조직명만 표시 — 이름/직급은 하단에 별도 표시)
if (!string.IsNullOrWhiteSpace(company))
CompanyNameText.Text = company;
// 목적
if (!string.IsNullOrWhiteSpace(purpose))
PurposeText.Text = purpose;
// 저작권 + 빌드 정보
if (!string.IsNullOrWhiteSpace(copyright))
{
var ver = version != null ? $"v{version.Major}.{version.Minor}.{version.Build}" : "v1.0";
BuildInfoText.Text = $"{appName} \u00b7 {ver} \u00b7 Commander + Agent \u00b7 {copyright}";
}
// 블로그 URL
if (!string.IsNullOrWhiteSpace(blogUrl) && BlogLinkBtn != null)
BlogLinkBtn.Content = blogUrl;
// 기여자 목록
var contributors = Get("contributors");
if (!string.IsNullOrWhiteSpace(contributors))
{
ContributorsText.Text = contributors;
ContributorsGrid.Visibility = Visibility.Visible;
}
}
catch { /* 브랜딩 로드 실패 시 기본값 유지 */ }
}
private void TryLoadAppIcon()
{
try
{
var uri = new Uri("pack://application:,,,/Assets/icon.ico");
var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream != null)
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.StreamSource = stream;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 176;
bmp.EndInit();
bmp.Freeze();
AppIconImage.Source = bmp;
FallbackIcon.Visibility = Visibility.Collapsed;
}
}
catch { }
}
private void TryLoadMascot()
{
// 내장 리소스에서 마스코트 로드 (오버레이용, 상단에는 표시 안 함)
foreach (var name in new[] { "mascot.png", "mascot.jpg", "mascot.webp" })
{
try
{
var uri = new Uri($"pack://application:,,,/Assets/{name}");
var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream == null) continue;
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.StreamSource = stream;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 400;
bmp.EndInit();
bmp.Freeze();
MascotImage.Source = bmp;
return;
}
catch { }
}
// 파일 시스템 폴백
var exeDir = Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory;
foreach (var name in new[] { "mascot.png", "mascot.jpg", "mascot.webp" })
{
var path = Path.Combine(exeDir, "Assets", name);
if (!File.Exists(path)) continue;
try
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path, UriKind.Absolute);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 400;
bmp.EndInit();
MascotImage.Source = bmp;
return;
}
catch { }
}
}
private void ShowMascot_Click(object sender, MouseButtonEventArgs e)
{
if (MascotImage.Source == null) return;
MascotOverlayImage.Source = MascotImage.Source;
MascotOverlay.Visibility = Visibility.Visible;
e.Handled = true; // DragMove 방지
}
private void HideMascot_Click(object sender, MouseButtonEventArgs e)
{
MascotOverlay.Visibility = Visibility.Collapsed;
e.Handled = true;
}
// 창 드래그 이동
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed)
try { DragMove(); } catch { }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private void Blog_Click(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo("https://www.swarchitect.net")
{ UseShellExecute = true });
}
catch { /* 브라우저 열기 실패 무시 */ }
}
}

View File

@@ -0,0 +1,145 @@
<Window x:Class="AxCopilot.Views.AgentStatsDashboardWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="에이전트 실행 통계"
Width="780" Height="580"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.3" Color="Black"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ── 타이틀바 ── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0" Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="16,0,0,0">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#A78BFA" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="에이전트 실행 통계" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,12,0">
<Border x:Name="BtnClose" Width="28" Height="28" CornerRadius="6" Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#33FF4444"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- ── 기간 필터 탭 ── -->
<Border Grid.Row="1" Padding="16,10,16,0">
<StackPanel Orientation="Horizontal">
<Border x:Name="FilterToday" Tag="1" CornerRadius="8" Padding="14,5" Margin="0,0,6,0"
Background="#4B5EFC" Cursor="Hand" MouseLeftButtonUp="Filter_Click">
<TextBlock Text="오늘" FontSize="12" FontWeight="SemiBold" Foreground="White"/>
</Border>
<Border x:Name="Filter7d" Tag="7" CornerRadius="8" Padding="14,5" Margin="0,0,6,0"
Background="{DynamicResource ItemBackground}" Cursor="Hand" MouseLeftButtonUp="Filter_Click">
<TextBlock Text="7일" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border x:Name="Filter30d" Tag="30" CornerRadius="8" Padding="14,5" Margin="0,0,6,0"
Background="{DynamicResource ItemBackground}" Cursor="Hand" MouseLeftButtonUp="Filter_Click">
<TextBlock Text="30일" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border x:Name="FilterAll" Tag="0" CornerRadius="8" Padding="14,5"
Background="{DynamicResource ItemBackground}" Cursor="Hand" MouseLeftButtonUp="Filter_Click">
<TextBlock Text="전체" FontSize="12" FontWeight="SemiBold" Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border Width="1" Background="{DynamicResource BorderColor}" Margin="14,2" VerticalAlignment="Stretch"/>
<Border CornerRadius="8" Padding="14,5" Background="{DynamicResource ItemBackground}"
Cursor="Hand" MouseLeftButtonUp="BtnClearStats_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74D;" FontFamily="Segoe MDL2 Assets" FontSize="11"
Foreground="#F87171" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="초기화" FontSize="12" Foreground="#F87171"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ── 메인 콘텐츠 ── -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" Padding="16,10,16,0">
<StackPanel>
<!-- 요약 카드 4종 -->
<UniformGrid x:Name="SummaryCards" Columns="4" Margin="0,0,0,12"/>
<!-- 일별 활동 차트 -->
<Border Background="{DynamicResource ItemBackground}" CornerRadius="10"
Padding="16,12" Margin="0,0,0,10">
<StackPanel>
<TextBlock x:Name="DailyChartTitle" Text="일별 실행 횟수"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,10"/>
<Canvas x:Name="DailyBarChart" Height="100" ClipToBounds="True"/>
<Canvas x:Name="DailyBarLabels" Height="18" ClipToBounds="True" Margin="0,2,0,0"/>
</StackPanel>
</Border>
<!-- 도구 사용 빈도 TOP 10 + 모델 분포 -->
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<!-- 도구 TOP 10 -->
<Border Grid.Column="0" Background="{DynamicResource ItemBackground}"
CornerRadius="10" Padding="16,12" Margin="0,0,8,0">
<StackPanel>
<TextBlock Text="도구 사용 TOP 10" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,10"/>
<StackPanel x:Name="ToolFreqPanel"/>
</StackPanel>
</Border>
<!-- 모델 / 탭 분포 -->
<Border Grid.Column="1" Background="{DynamicResource ItemBackground}"
CornerRadius="10" Padding="16,12">
<StackPanel>
<TextBlock Text="탭별 실행" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,10"/>
<StackPanel x:Name="TabBreakPanel"/>
<TextBlock Text="모델별" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,10,0,8"/>
<StackPanel x:Name="ModelBreakPanel"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
<!-- ── 상태바 ── -->
<Border Grid.Row="3" BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Padding="16,8" CornerRadius="0,0,12,12">
<TextBlock x:Name="StatusText" FontSize="11"
Foreground="{DynamicResource SecondaryText}"
Text="데이터를 불러오는 중..."/>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,398 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using AxCopilot.Services;
namespace AxCopilot.Views;
/// <summary>에이전트 실행 통계 대시보드 창.</summary>
public partial class AgentStatsDashboardWindow : Window
{
private int _currentDays = 1; // 현재 필터 (오늘=1)
public AgentStatsDashboardWindow()
{
InitializeComponent();
Loaded += (_, _) => LoadStats(_currentDays);
}
// ─── 이벤트 핸들러 ────────────────────────────────────────────────────
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// 타이틀바 우측 닫기 버튼 영역에서는 DragMove 실행하지 않음
var pos = e.GetPosition(this);
if (pos.X > ActualWidth - 50) return;
if (e.ClickCount == 2)
{
WindowState = WindowState == WindowState.Maximized
? WindowState.Normal : WindowState.Maximized;
}
else DragMove();
}
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
private void Filter_Click(object sender, MouseButtonEventArgs e)
{
if (sender is not Border b || b.Tag is not string tagStr) return;
if (!int.TryParse(tagStr, out var days)) return;
_currentDays = days;
UpdateFilterButtons(b);
LoadStats(days);
}
private void BtnClearStats_Click(object sender, MouseButtonEventArgs e)
{
var result = CustomMessageBox.Show(
"모든 통계 데이터를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
"통계 초기화",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
AgentStatsService.Clear();
LoadStats(_currentDays);
}
// ─── 통계 로드/렌더링 ─────────────────────────────────────────────────
private void LoadStats(int days)
{
var summary = AgentStatsService.Aggregate(days);
RenderSummaryCards(summary);
RenderDailyChart(summary, days);
RenderToolFreq(summary);
RenderBreakdowns(summary);
UpdateStatus(summary, days);
}
private void RenderSummaryCards(AgentStatsService.AgentStatsSummary s)
{
SummaryCards.Children.Clear();
SummaryCards.Children.Add(MakeSummaryCard("\uE9D9", "총 실행", s.TotalSessions.ToString("N0"), "#A78BFA"));
SummaryCards.Children.Add(MakeSummaryCard("\uE8A7", "도구 호출", s.TotalToolCalls.ToString("N0"), "#3B82F6"));
SummaryCards.Children.Add(MakeSummaryCard("\uE7C8", "총 토큰", FormatTokens(s.TotalTokens), "#10B981"));
SummaryCards.Children.Add(MakeSummaryCard("\uE73E", "재시도 품질",
$"{s.RetryQualityRate * 100:F0}%",
"#F59E0B"));
}
private Border MakeSummaryCard(string icon, string label, string value, string colorHex)
{
var bg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2A, 0x40));
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var sub = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var col = (Color)ColorConverter.ConvertFromString(colorHex);
var card = new Border
{
Background = bg,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 12, 14, 12),
Margin = new Thickness(0, 0, 8, 0),
};
var sp = new StackPanel();
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 16,
Foreground = new SolidColorBrush(col),
Margin = new Thickness(0, 0, 0, 6),
});
sp.Children.Add(new TextBlock
{
Text = value,
FontSize = 20,
FontWeight = FontWeights.Bold,
Foreground = fg,
});
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 11,
Foreground = sub,
Margin = new Thickness(0, 2, 0, 0),
});
card.Child = sp;
return card;
}
private void RenderDailyChart(AgentStatsService.AgentStatsSummary s, int days)
{
DailyBarChart.Children.Clear();
DailyBarLabels.Children.Clear();
// 최근 N일 날짜 범위 구성
var endDate = DateTime.Today;
int chartDays = days == 1 ? 1 : days == 0 ? 30 : days;
var dates = Enumerable.Range(0, chartDays)
.Select(i => endDate.AddDays(-(chartDays - 1 - i)))
.ToList();
var maxVal = dates
.Select(d => s.DailySessions.GetValueOrDefault(d.ToString("yyyy-MM-dd"), 0))
.DefaultIfEmpty(0).Max();
if (maxVal == 0) maxVal = 1;
var barColor = (Color)ColorConverter.ConvertFromString("#4B5EFC");
var chartW = 748.0; // 근사값 (Canvas는 레이아웃 후 ActualWidth 확정)
var barGap = 2.0;
var barW = Math.Max(4, (chartW - barGap * dates.Count) / dates.Count);
var chartH = 100.0;
for (int i = 0; i < dates.Count; i++)
{
var dateKey = dates[i].ToString("yyyy-MM-dd");
var val = s.DailySessions.GetValueOrDefault(dateKey, 0);
var barH = Math.Max(2, val / (double)maxVal * (chartH - 4));
var x = i * (barW + barGap);
var rect = new Rectangle
{
Width = barW,
Height = barH,
Fill = new SolidColorBrush(Color.FromArgb(0xCC,
barColor.R, barColor.G, barColor.B)),
RadiusX = 3,
RadiusY = 3,
ToolTip = $"{dateKey}: {val}회",
};
Canvas.SetLeft(rect, x);
Canvas.SetTop(rect, chartH - barH);
DailyBarChart.Children.Add(rect);
// 레이블: 7일 이하면 모두 표시, 그 이상이면 월요일만
if (dates.Count <= 7 || dates[i].DayOfWeek == DayOfWeek.Monday)
{
var lbl = new TextBlock
{
Text = dates[i].ToString("MM/dd"),
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
};
Canvas.SetLeft(lbl, x);
DailyBarLabels.Children.Add(lbl);
}
}
}
private void RenderToolFreq(AgentStatsService.AgentStatsSummary s)
{
ToolFreqPanel.Children.Clear();
if (s.ToolFrequency.Count == 0)
{
ToolFreqPanel.Children.Add(new TextBlock
{
Text = "데이터 없음",
FontSize = 12,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
});
return;
}
var maxVal = s.ToolFrequency.Values.Max();
var barColor = (Color)ColorConverter.ConvertFromString("#3B82F6");
foreach (var (tool, count) in s.ToolFrequency)
{
var ratio = (double)count / maxVal;
var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
row.ColumnDefinitions.Add(new ColumnDefinition());
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(36) });
var nameTb = new TextBlock
{
Text = tool,
FontSize = 11,
FontFamily = new FontFamily("Consolas"),
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
TextTrimming = TextTrimming.CharacterEllipsis,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(nameTb, 0);
var barBg = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, 0x3B, 0x82, 0xF6)),
CornerRadius = new CornerRadius(3),
Height = 10,
Margin = new Thickness(6, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
};
var barFg = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xCC, barColor.R, barColor.G, barColor.B)),
CornerRadius = new CornerRadius(3),
Height = 10,
HorizontalAlignment = HorizontalAlignment.Left,
};
barFg.Width = ratio * 150; // 최대 150px
barBg.Child = barFg;
Grid.SetColumn(barBg, 1);
var countTb = new TextBlock
{
Text = count.ToString(),
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(countTb, 2);
row.Children.Add(nameTb);
row.Children.Add(barBg);
row.Children.Add(countTb);
ToolFreqPanel.Children.Add(row);
}
}
private void RenderBreakdowns(AgentStatsService.AgentStatsSummary s)
{
// 탭 분포
TabBreakPanel.Children.Clear();
var tabColors = new Dictionary<string, string>
{
["Cowork"] = "#A78BFA",
["Code"] = "#34D399",
["Chat"] = "#3B82F6",
};
foreach (var (tab, count) in s.TabBreakdown.OrderByDescending(kv => kv.Value))
{
var c = tabColors.GetValueOrDefault(tab, "#9CA3AF");
TabBreakPanel.Children.Add(MakeBreakRow(tab, count, c,
s.TabBreakdown.Values.DefaultIfEmpty(1).Max()));
}
if (s.TabBreakdown.Count == 0)
TabBreakPanel.Children.Add(MakeEmptyText());
// 모델 분포
ModelBreakPanel.Children.Clear();
foreach (var (model, count) in s.ModelBreakdown.OrderByDescending(kv => kv.Value))
{
var shortName = model.Length > 18 ? model[..18] + "…" : model;
ModelBreakPanel.Children.Add(MakeBreakRow(shortName, count, "#F59E0B",
s.ModelBreakdown.Values.DefaultIfEmpty(1).Max()));
}
if (s.ModelBreakdown.Count == 0)
ModelBreakPanel.Children.Add(MakeEmptyText());
}
private UIElement MakeBreakRow(string label, int count, string colorHex, int maxVal)
{
var ratio = maxVal > 0 ? (double)count / maxVal : 0;
var col = (Color)ColorConverter.ConvertFromString(colorHex);
var row = new StackPanel { Margin = new Thickness(0, 2, 0, 2) };
var header = new Grid();
header.ColumnDefinitions.Add(new ColumnDefinition());
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) });
var lbl = new TextBlock
{
Text = label,
FontSize = 11,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
};
Grid.SetColumn(lbl, 0);
var cnt = new TextBlock
{
Text = count.ToString(),
FontSize = 11,
FontWeight= FontWeights.SemiBold,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
};
Grid.SetColumn(cnt, 1);
header.Children.Add(lbl);
header.Children.Add(cnt);
row.Children.Add(header);
// 바 차트
var barBg = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x20, col.R, col.G, col.B)),
CornerRadius = new CornerRadius(3),
Height = 6,
Margin = new Thickness(0, 3, 0, 0),
};
var barFg = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xCC, col.R, col.G, col.B)),
CornerRadius = new CornerRadius(3),
Height = 6,
HorizontalAlignment = HorizontalAlignment.Left,
};
barFg.Width = ratio * 160; // 최대 160px
barBg.Child = barFg;
row.Children.Add(barBg);
return row;
}
private TextBlock MakeEmptyText() => new()
{
Text = "데이터 없음",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
};
private void UpdateFilterButtons(Border active)
{
foreach (var btn in new[] { FilterToday, Filter7d, Filter30d, FilterAll })
{
var isActive = btn == active;
btn.Background = isActive
? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))
: TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x30, 0x30, 0x45));
if (btn.Child is TextBlock tb)
{
tb.Foreground = isActive
? Brushes.White
: TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
}
}
}
private void UpdateStatus(AgentStatsService.AgentStatsSummary s, int days)
{
var period = days switch
{
1 => "오늘",
7 => "최근 7일",
30 => "최근 30일",
_ => "전체 기간",
};
var fileSize = AgentStatsService.GetFileSize();
var taskQuality = string.Join(", ",
s.RetryQualityByTaskType
.OrderByDescending(kv => kv.Value)
.ThenBy(kv => kv.Key, StringComparer.Ordinal)
.Take(3)
.Select(kv => $"{kv.Key} {kv.Value * 100:F0}%"));
var taskQualityText = string.IsNullOrWhiteSpace(taskQuality)
? "task 품질 데이터 없음"
: $"task 품질 {taskQuality}";
StatusText.Text =
$"{period} 기준 | 세션 {s.TotalSessions}회, 도구 호출 {s.TotalToolCalls}회, " +
$"토큰 {FormatTokens(s.TotalTokens)} | 재시도 복구 {s.TotalRecoveredAfterFailure}, 반복차단 {s.TotalRepeatedFailureBlocked} | {taskQualityText} | 저장 파일 {FormatFileSize(fileSize)}";
}
private static string FormatTokens(int t) =>
t >= 1_000_000 ? $"{t / 1_000_000.0:F1}M"
: t >= 1_000 ? $"{t / 1_000.0:F1}K"
: t.ToString();
private static string FormatFileSize(long bytes) =>
bytes >= 1024 * 1024 ? $"{bytes / (1024.0 * 1024):F1}MB"
: bytes >= 1024 ? $"{bytes / 1024.0:F1}KB"
: $"{bytes}B";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
<Window x:Class="AxCopilot.Views.ColorPickResultWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
SizeToContent="WidthAndHeight"
WindowStartupLocation="Manual">
<Border CornerRadius="14"
Padding="20,16"
Margin="16">
<Border.Background>
<SolidColorBrush Color="#1A1B2E" Opacity="0.92"/>
</Border.Background>
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="22" ShadowDepth="4" Opacity="0.28"/>
</Border.Effect>
<StackPanel>
<!-- 색상 미리보기 원 -->
<Ellipse x:Name="ColorPreview"
Width="56" Height="56"
Stroke="#44FFFFFF" StrokeThickness="2"
HorizontalAlignment="Center"/>
<!-- HEX 코드 -->
<TextBlock x:Name="HexText"
FontSize="22" FontWeight="Bold"
FontFamily="Consolas"
Foreground="White"
HorizontalAlignment="Center"
Margin="0,10,0,2"/>
<!-- RGB 값 -->
<TextBlock x:Name="RgbText"
FontSize="12"
FontFamily="Consolas"
Foreground="#88AACCFF"
HorizontalAlignment="Center"/>
<!-- 힌트 -->
<TextBlock Text="HEX 코드가 클립보드에 복사되었습니다"
FontSize="10"
Foreground="#55FFFFFF"
HorizontalAlignment="Center"
Margin="0,8,0,0"/>
</StackPanel>
</Border>
</Window>

View File

@@ -0,0 +1,57 @@
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
namespace AxCopilot.Views;
/// <summary>
/// 스포이드 색상 추출 결과를 반투명 창으로 5초간 표시합니다.
/// 클릭 위치 근처에 나타나며, HEX 코드를 클립보드에 복사합니다.
/// </summary>
public partial class ColorPickResultWindow : Window
{
private readonly DispatcherTimer _timer;
public ColorPickResultWindow(System.Drawing.Color color, double screenX, double screenY)
{
InitializeComponent();
// 색상 표시
var wpfColor = Color.FromRgb(color.R, color.G, color.B);
ColorPreview.Fill = new SolidColorBrush(wpfColor);
HexText.Text = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
RgbText.Text = $"RGB({color.R}, {color.G}, {color.B})";
// 클립보드에 HEX 코드 복사
try { Clipboard.SetText(HexText.Text); }
catch { }
// 위치: 클릭 지점 오른쪽 아래
var area = SystemParameters.WorkArea;
Left = Math.Min(screenX + 20, area.Right - 200);
Top = Math.Min(screenY + 20, area.Bottom - 160);
// 5초 타이머
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_timer.Tick += (_, _) => Close();
_timer.Start();
// 클릭으로 닫기
MouseDown += (_, _) => Close();
// 등장 애니메이션
Opacity = 0;
Loaded += (_, _) =>
{
var anim = new System.Windows.Media.Animation.DoubleAnimation(0, 1,
TimeSpan.FromMilliseconds(200));
BeginAnimation(OpacityProperty, anim);
};
}
protected override void OnClosed(EventArgs e)
{
_timer.Stop();
base.OnClosed(e);
}
}

View File

@@ -0,0 +1,48 @@
<Window x:Class="AxCopilot.Views.CommandPaletteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Command Palette"
Width="520" SizeToContent="Height"
MaxHeight="480"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Topmost="True"
PreviewKeyDown="Window_PreviewKeyDown"
Deactivated="Window_Deactivated">
<Border Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
CornerRadius="14" Padding="8" Margin="16">
<Border.Effect>
<DropShadowEffect BlurRadius="24" ShadowDepth="6" Opacity="0.35"
Color="Black" Direction="270"/>
</Border.Effect>
<StackPanel>
<!-- 검색 입력 -->
<Border Background="{DynamicResource ItemBackground}" CornerRadius="10"
Padding="10,8" Margin="4,4,4,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="&#xE721;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="SearchBox" Grid.Column="1"
FontSize="14" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource PrimaryText}"
CaretBrush="{DynamicResource AccentColor}"
TextChanged="SearchBox_TextChanged"/>
</Grid>
</Border>
<!-- 결과 목록 -->
<ScrollViewer MaxHeight="360" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="ResultPanel" Margin="4,0,4,4"/>
</ScrollViewer>
</StackPanel>
</Border>
</Window>

View File

@@ -0,0 +1,147 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace AxCopilot.Views;
/// <summary>
/// Ctrl+Shift+P로 호출되는 커맨드 팔레트.
/// 모든 기능에 빠르게 접근할 수 있는 통합 명령 검색 창입니다.
/// </summary>
public partial class CommandPaletteWindow : Window
{
private readonly Action<string>? _onExecute;
private readonly List<CommandEntry> _commands = new();
// 향후 키보드 탐색용
// private int _selectedIndex = -1;
public string? SelectedCommand { get; private set; }
public CommandPaletteWindow(Action<string>? onExecute = null)
{
InitializeComponent();
_onExecute = onExecute;
RegisterCommands();
RenderItems("");
Loaded += (_, _) => { SearchBox.Focus(); };
}
private void RegisterCommands()
{
// 탭 전환
_commands.Add(new("Chat 탭으로 전환", "탭 전환", "\uE8BD", "tab:chat"));
_commands.Add(new("Cowork 탭으로 전환", "탭 전환", "\uE8BD", "tab:cowork"));
_commands.Add(new("Code 탭으로 전환", "탭 전환", "\uE8BD", "tab:code"));
// 대화 관리
_commands.Add(new("새 대화 시작", "대화", "\uE710", "new_conversation"));
_commands.Add(new("대화 검색", "대화", "\uE721", "search_conversation"));
// 모델 변경
_commands.Add(new("모델 변경", "설정", "\uEA86", "change_model"));
// 프리셋
_commands.Add(new("프리셋 선택", "프리셋", "\uE71D", "select_preset"));
// 설정
_commands.Add(new("설정 열기", "앱", "\uE713", "open_settings"));
_commands.Add(new("테마 변경", "앱", "\uE790", "change_theme"));
_commands.Add(new("통계 보기", "앱", "\uE9F9", "open_statistics"));
// 파일
_commands.Add(new("작업 폴더 변경", "파일", "\uED25", "change_folder"));
_commands.Add(new("파일 탐색기 열기", "파일", "\uE8B7", "open_file_explorer"));
// 에이전트
_commands.Add(new("개발자 모드 토글", "에이전트", "\uE71C", "toggle_devmode"));
_commands.Add(new("감사 로그 보기", "에이전트", "\uE9D9", "open_audit_log"));
// 도구
_commands.Add(new("클립보드에서 붙여넣기", "도구", "\uE77F", "paste_clipboard"));
_commands.Add(new("대화 내보내기", "도구", "\uE78C", "export_conversation"));
}
private void RenderItems(string query)
{
ResultPanel.Children.Clear();
var filtered = string.IsNullOrWhiteSpace(query)
? _commands
: _commands.Where(c =>
c.Label.Contains(query, StringComparison.OrdinalIgnoreCase) ||
c.Category.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(Core.FuzzyEngine.IsChosung(query) && Core.FuzzyEngine.ContainsChosung(c.Label, query))).ToList();
for (int i = 0; i < filtered.Count; i++)
{
var cmd = filtered[i];
var idx = i;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = cmd.Icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14, Foreground = FindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
});
var textSp = new StackPanel();
textSp.Children.Add(new TextBlock
{
Text = cmd.Label, FontSize = 13,
Foreground = FindResource("PrimaryText") as Brush ?? Brushes.White,
});
textSp.Children.Add(new TextBlock
{
Text = cmd.Category, FontSize = 10.5,
Foreground = FindResource("SecondaryText") as Brush ?? Brushes.Gray,
});
sp.Children.Add(textSp);
var item = new Border
{
Child = sp, Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand,
Padding = new Thickness(10, 8, 14, 8), Margin = new Thickness(0, 1, 0, 1),
};
var hoverBg = FindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
item.MouseLeftButtonUp += (_, _) => ExecuteCommand(cmd.CommandId);
ResultPanel.Children.Add(item);
}
}
private void ExecuteCommand(string commandId)
{
SelectedCommand = commandId;
_onExecute?.Invoke(commandId);
Close();
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
RenderItems(SearchBox.Text);
}
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) { Close(); e.Handled = true; }
else if (e.Key == Key.Enter && ResultPanel.Children.Count > 0)
{
// 첫 번째 항목 실행
var filtered = string.IsNullOrWhiteSpace(SearchBox.Text)
? _commands : _commands.Where(c =>
c.Label.Contains(SearchBox.Text, StringComparison.OrdinalIgnoreCase) ||
c.Category.Contains(SearchBox.Text, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count > 0) ExecuteCommand(filtered[0].CommandId);
e.Handled = true;
}
}
private void Window_Deactivated(object sender, EventArgs e) => Close();
private record CommandEntry(string Label, string Category, string Icon, string CommandId);
}

View File

@@ -0,0 +1,350 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
namespace AxCopilot.Views;
/// <summary>
/// 기본 MessageBox를 대체하는 커스텀 다이얼로그.
/// 테마 리소스를 사용하여 앱 디자인과 일관된 모습을 제공합니다.
/// </summary>
internal sealed class CustomMessageBox : Window
{
private MessageBoxResult _result = MessageBoxResult.None;
private CustomMessageBox(string message, string title, MessageBoxButton buttons, MessageBoxImage icon)
{
Title = title;
Width = 400;
MinWidth = 320;
MaxWidth = 520;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
Topmost = true; // 다른 창 위에 표시
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
// 루트 컨테이너
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(28, 24, 28, 20),
Effect = new DropShadowEffect
{
BlurRadius = 24, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
// 타이틀 바 (드래그 가능)
var titleBar = new Grid { Margin = new Thickness(0, 0, 0, 16) };
titleBar.MouseLeftButtonDown += (_, _) => { try { DragMove(); } catch { } };
// 아이콘 + 제목
var titlePanel = new StackPanel { Orientation = Orientation.Horizontal };
var (iconText, iconColor) = GetIconInfo(icon);
if (!string.IsNullOrEmpty(iconText))
{
titlePanel.Children.Add(new TextBlock
{
Text = iconText,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
}
titlePanel.Children.Add(new TextBlock
{
Text = title,
FontSize = 15,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
titleBar.Children.Add(titlePanel);
// 닫기 버튼
var closeBtn = new Button
{
Content = "\uE8BB",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = secondaryText,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(6),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
closeBtn.Click += (_, _) => { _result = MessageBoxResult.Cancel; Close(); };
titleBar.Children.Add(closeBtn);
stack.Children.Add(titleBar);
// 메시지 본문
stack.Children.Add(new TextBlock
{
Text = message,
FontSize = 13,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
Margin = new Thickness(0, 0, 0, 24),
});
// 버튼 영역
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
};
switch (buttons)
{
case MessageBoxButton.OK:
btnPanel.Children.Add(CreateButton("확인", accentBrush, true, MessageBoxResult.OK));
break;
case MessageBoxButton.OKCancel:
btnPanel.Children.Add(CreateButton("취소", itemBg, false, MessageBoxResult.Cancel));
btnPanel.Children.Add(CreateButton("확인", accentBrush, true, MessageBoxResult.OK));
break;
case MessageBoxButton.YesNo:
btnPanel.Children.Add(CreateButton("아니오", itemBg, false, MessageBoxResult.No));
btnPanel.Children.Add(CreateButton("예", accentBrush, true, MessageBoxResult.Yes));
break;
case MessageBoxButton.YesNoCancel:
btnPanel.Children.Add(CreateButton("취소", itemBg, false, MessageBoxResult.Cancel));
btnPanel.Children.Add(CreateButton("아니오", itemBg, false, MessageBoxResult.No));
btnPanel.Children.Add(CreateButton("예", accentBrush, true, MessageBoxResult.Yes));
break;
}
stack.Children.Add(btnPanel);
root.Child = stack;
Content = root;
// ESC로 닫기
KeyDown += (_, e) =>
{
if (e.Key == Key.Escape)
{
_result = buttons == MessageBoxButton.YesNo ? MessageBoxResult.No : MessageBoxResult.Cancel;
Close();
}
else if (e.Key == Key.Enter)
{
_result = buttons switch
{
MessageBoxButton.YesNo or MessageBoxButton.YesNoCancel => MessageBoxResult.Yes,
_ => MessageBoxResult.OK,
};
Close();
}
};
}
private Button CreateButton(string text, Brush bg, bool isPrimary, MessageBoxResult result)
{
var btn = new Button
{
Content = text,
FontSize = 12.5,
FontWeight = isPrimary ? FontWeights.SemiBold : FontWeights.Normal,
Foreground = isPrimary ? Brushes.White : (Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White),
Background = bg,
BorderThickness = new Thickness(0),
Padding = new Thickness(20, 8, 20, 8),
Margin = new Thickness(6, 0, 0, 0),
Cursor = Cursors.Hand,
MinWidth = 80,
};
// 라운드 코너 템플릿
var template = new ControlTemplate(typeof(Button));
var border = new FrameworkElementFactory(typeof(Border));
border.SetValue(Border.BackgroundProperty, bg);
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(10));
border.SetValue(Border.PaddingProperty, new Thickness(20, 8, 20, 8));
var cp = new FrameworkElementFactory(typeof(ContentPresenter));
cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
border.AppendChild(cp);
template.VisualTree = border;
btn.Template = template;
btn.Click += (_, _) => { _result = result; Close(); };
return btn;
}
private static (string icon, Brush color) GetIconInfo(MessageBoxImage image) => image switch
{
MessageBoxImage.Error => ("\uEA39", new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E))),
MessageBoxImage.Warning => ("\uE7BA", new SolidColorBrush(Color.FromRgb(0xDD, 0x6B, 0x20))),
MessageBoxImage.Information => ("\uE946", new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))),
MessageBoxImage.Question => ("\uE9CE", new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))),
_ => ("", Brushes.Transparent),
};
// ─── 정적 호출 메서드 (기존 MessageBox.Show 시그니처 호환) ──────────────
public static MessageBoxResult Show(string message)
=> Show(message, "AX Copilot", MessageBoxButton.OK, MessageBoxImage.None);
public static MessageBoxResult Show(string message, string title)
=> Show(message, title, MessageBoxButton.OK, MessageBoxImage.None);
public static MessageBoxResult Show(string message, string title, MessageBoxButton buttons)
=> Show(message, title, buttons, MessageBoxImage.None);
public static MessageBoxResult Show(string message, string title, MessageBoxButton buttons, MessageBoxImage icon)
{
var dlg = new CustomMessageBox(message, title, buttons, icon);
// 등장 애니메이션 (페이드인 + 스케일)
dlg.Opacity = 0;
dlg.Loaded += (_, _) =>
{
var fadeIn = new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(150));
dlg.BeginAnimation(OpacityProperty, fadeIn);
};
// ─── 부모 Window 하단에 토스트 알림 표시 ───
var parentWindow = Application.Current.Windows.OfType<Window>()
.FirstOrDefault(w => w.IsActive && w is not CustomMessageBox)
?? Application.Current.MainWindow;
Border? toast = null;
Grid? rootGrid = null;
if (parentWindow?.Content is Border outerBorder && outerBorder.Child is Grid grid)
{
rootGrid = grid;
toast = CreateToastBanner(title, icon);
rootGrid.Children.Add(toast);
}
else if (parentWindow?.Content is Grid directGrid)
{
rootGrid = directGrid;
toast = CreateToastBanner(title, icon);
rootGrid.Children.Add(toast);
}
dlg.ShowDialog();
// 다이얼로그 닫힌 후 토스트 페이드아웃
if (toast != null && rootGrid != null)
{
var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(400))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn },
};
fadeOut.Completed += (_, _) => rootGrid.Children.Remove(toast);
toast.BeginAnimation(OpacityProperty, fadeOut);
}
return dlg._result;
}
/// <summary>하단 토스트 배너 생성.</summary>
private static Border CreateToastBanner(string title, MessageBoxImage icon)
{
var (iconText, iconColor) = GetIconInfo(icon);
if (string.IsNullOrEmpty(iconText))
{
iconText = "\uE946"; // default info icon
iconColor = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
}
var panel = new StackPanel { Orientation = Orientation.Horizontal };
panel.Children.Add(new TextBlock
{
Text = iconText,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
panel.Children.Add(new TextBlock
{
Text = title,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
});
var toast = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xE8, 0x2A, 0x2B, 0x40)),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(16, 8, 16, 8),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 12),
Effect = new DropShadowEffect
{
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
},
Opacity = 0,
Child = panel,
};
// 모든 Grid Row/Column 스팬하도록 설정
Grid.SetColumnSpan(toast, 10);
Grid.SetRowSpan(toast, 10);
// 슬라이드업 + 페이드인 애니메이션
var translate = new TranslateTransform(0, 20);
toast.RenderTransform = translate;
toast.Loaded += (_, _) =>
{
var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(250))
{
EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut },
};
var slideUp = new DoubleAnimation(20, 0, TimeSpan.FromMilliseconds(300))
{
EasingFunction = new BackEase { EasingMode = EasingMode.EaseOut, Amplitude = 0.3 },
};
toast.BeginAnimation(OpacityProperty, fadeIn);
translate.BeginAnimation(TranslateTransform.YProperty, slideUp);
};
return toast;
}
/// <summary>WinForms 호환 — 인스톨러에서도 사용 가능하도록 Window 없이 표시.</summary>
public static MessageBoxResult ShowStandalone(string message, string title,
MessageBoxButton buttons = MessageBoxButton.OK, MessageBoxImage icon = MessageBoxImage.None)
{
var dlg = new CustomMessageBox(message, title, buttons, icon);
dlg.ShowDialog();
return dlg._result;
}
}

View File

@@ -0,0 +1,283 @@
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace AxCopilot.Views;
/// <summary>커스텀 디자인 무드 추가/편집 다이얼로그.</summary>
internal sealed partial class CustomMoodDialog : Window
{
private readonly TextBox _keyBox;
private readonly TextBox _labelBox;
private readonly TextBox _iconBox;
private readonly TextBox _descBox;
private readonly TextBox _cssBox;
private readonly bool _isEdit;
public string MoodKey => _keyBox.Text.Trim().ToLowerInvariant();
public string MoodLabel => _labelBox.Text.Trim();
public string MoodIcon => _iconBox.Text.Trim();
public string MoodDescription => _descBox.Text.Trim();
public string MoodCss => _cssBox.Text;
public CustomMoodDialog(
string existingKey = "",
string existingLabel = "",
string existingIcon = "🎯",
string existingDesc = "",
string existingCss = "")
{
_isEdit = !string.IsNullOrEmpty(existingKey);
Title = _isEdit ? "무드 편집" : "무드 추가";
Width = 560;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(28, 24, 28, 24),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 24, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
// 헤더
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 20) };
header.Children.Add(new TextBlock
{
Text = "\uE771",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
header.Children.Add(new TextBlock
{
Text = _isEdit ? "디자인 무드 편집" : "새 커스텀 디자인 무드",
FontSize = 17, FontWeight = FontWeights.Bold,
Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(header);
// ── 키 + 라벨 + 아이콘 (한 줄) ──
var row1 = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
// 키
var keyStack = new StackPanel { Margin = new Thickness(0, 0, 12, 0) };
AddLabel(keyStack, "키 (영문)", primaryText);
_keyBox = CreateTextBox(existingKey, primaryText, itemBg, accentBrush, borderBrush);
_keyBox.Width = 140;
_keyBox.IsEnabled = !_isEdit; // 편집 시 키 변경 불가
keyStack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _keyBox });
row1.Children.Add(keyStack);
// 라벨
var labelStack = new StackPanel { Margin = new Thickness(0, 0, 12, 0) };
AddLabel(labelStack, "이름", primaryText);
_labelBox = CreateTextBox(existingLabel, primaryText, itemBg, accentBrush, borderBrush);
_labelBox.Width = 180;
labelStack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _labelBox });
row1.Children.Add(labelStack);
// 아이콘 (이모지)
var iconStack = new StackPanel();
AddLabel(iconStack, "아이콘", primaryText);
_iconBox = CreateTextBox(existingIcon, primaryText, itemBg, accentBrush, borderBrush);
_iconBox.Width = 60;
_iconBox.TextAlignment = TextAlignment.Center;
iconStack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _iconBox });
row1.Children.Add(iconStack);
stack.Children.Add(row1);
// ── 설명 ──
AddLabel(stack, "설명", primaryText);
_descBox = CreateTextBox(existingDesc, primaryText, itemBg, accentBrush, borderBrush);
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _descBox, Margin = new Thickness(0, 0, 0, 8) });
// ── CSS ──
AddSeparator(stack, borderBrush);
AddLabel(stack, "CSS 스타일", primaryText);
AddHint(stack, "문서에 적용될 CSS입니다. body, h1~h6, table, .callout 등의 스타일을 정의하세요.", secondaryText);
_cssBox = CreateTextBox(existingCss, primaryText, itemBg, accentBrush, borderBrush, multiline: true, height: 200);
_cssBox.FontFamily = new FontFamily("Consolas, Courier New, monospace");
_cssBox.FontSize = 12;
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _cssBox });
// CSS 힌트 버튼
var hintBtn = new TextBlock
{
Text = "CSS 예시 보기",
FontSize = 11,
Foreground = accentBrush,
Cursor = Cursors.Hand,
Margin = new Thickness(0, 6, 0, 0),
TextDecorations = TextDecorations.Underline,
};
hintBtn.MouseLeftButtonDown += (_, _) =>
{
if (string.IsNullOrWhiteSpace(_cssBox.Text))
{
_cssBox.Text = CssExample;
}
};
stack.Children.Add(hintBtn);
// ── 버튼 바 ──
var btnBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 20, 0, 0),
};
var cancelBtn = new Button
{
Content = "취소", Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Margin = new Thickness(0, 0, 10, 0),
Background = Brushes.Transparent, Foreground = secondaryText,
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
Cursor = Cursors.Hand, FontSize = 13,
};
cancelBtn.Click += (_, _) => { DialogResult = false; Close(); };
btnBar.Children.Add(cancelBtn);
var okBtn = new Button
{
Content = _isEdit ? "저장" : "추가", Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Background = accentBrush, Foreground = Brushes.White,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, FontSize = 13, FontWeight = FontWeights.SemiBold,
};
okBtn.Click += (_, _) => Validate();
btnBar.Children.Add(okBtn);
stack.Children.Add(btnBar);
root.Child = stack;
Content = root;
KeyDown += (_, ke) => { if (ke.Key == Key.Escape) { DialogResult = false; Close(); } };
Loaded += (_, _) => { (_isEdit ? _labelBox : _keyBox).Focus(); };
root.MouseLeftButtonDown += (_, me) =>
{
if (me.LeftButton == MouseButtonState.Pressed)
try { DragMove(); } catch { }
};
}
private void Validate()
{
if (string.IsNullOrWhiteSpace(_keyBox.Text))
{
CustomMessageBox.Show("키를 입력하세요 (영문 소문자).", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_keyBox.Focus(); return;
}
if (!Regex.IsMatch(_keyBox.Text.Trim(), @"^[a-z][a-z0-9_]{1,20}$"))
{
CustomMessageBox.Show("키는 영문 소문자로 시작하며, 소문자/숫자/밑줄만 허용됩니다 (2~21자).", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_keyBox.Focus(); return;
}
if (string.IsNullOrWhiteSpace(_labelBox.Text))
{
CustomMessageBox.Show("이름을 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_labelBox.Focus(); return;
}
// 내장 무드 키와 충돌 확인
if (!_isEdit)
{
var builtinKeys = new[] { "modern", "professional", "creative", "minimal", "elegant", "dark", "colorful", "corporate", "magazine", "dashboard" };
if (builtinKeys.Contains(_keyBox.Text.Trim().ToLowerInvariant()))
{
CustomMessageBox.Show("내장 무드와 동일한 키는 사용할 수 없습니다.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_keyBox.Focus(); return;
}
}
DialogResult = true;
Close();
}
private static void AddLabel(StackPanel parent, string text, Brush fg) =>
parent.Children.Add(new TextBlock
{
Text = text, FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = fg, Margin = new Thickness(0, 0, 0, 6),
});
private static void AddHint(StackPanel parent, string text, Brush fg) =>
parent.Children.Add(new TextBlock
{
Text = text, FontSize = 11, Foreground = fg,
Margin = new Thickness(0, 0, 0, 8),
});
private static void AddSeparator(StackPanel parent, Brush color) =>
parent.Children.Add(new Rectangle
{
Height = 1, Fill = color,
Margin = new Thickness(0, 8, 0, 12), Opacity = 0.5,
});
private static TextBox CreateTextBox(string text, Brush fg, Brush bg, Brush caret, Brush border, bool multiline = false, int height = 100)
{
var tb = new TextBox
{
Text = text, FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Foreground = fg, Background = bg,
CaretBrush = caret, BorderBrush = border,
BorderThickness = new Thickness(1),
};
if (multiline)
{
tb.AcceptsReturn = true;
tb.AcceptsTab = true;
tb.TextWrapping = TextWrapping.Wrap;
tb.MinHeight = height;
tb.MaxHeight = height + 100;
tb.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
}
return tb;
}
private const string CssExample = @"body {
font-family: 'Pretendard', 'Noto Sans KR', sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 40px;
color: #1a1a2e;
background: #f8f9fc;
line-height: 1.8;
}
h1 { color: #2d3748; border-bottom: 2px solid #4a90d9; padding-bottom: 8px; }
h2 { color: #4a5568; margin-top: 2em; }
table { width: 100%; border-collapse: collapse; margin: 1.5em 0; }
th { background: #4a90d9; color: white; padding: 10px; text-align: left; }
td { padding: 8px 10px; border-bottom: 1px solid #e2e8f0; }
tr:nth-child(even) { background: #f0f4f8; }
";
}

View File

@@ -0,0 +1,483 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
using AxCopilot.Models;
namespace AxCopilot.Views;
/// <summary>커스텀 프리셋 추가/편집 다이얼로그.</summary>
internal sealed class CustomPresetDialog : Window
{
private readonly TextBox _nameBox;
private readonly TextBox _descBox;
private readonly TextBox _promptBox;
private readonly ComboBox _tabCombo;
private string _selectedColor;
private string _selectedSymbol;
private Border? _iconPreview;
private TextBlock? _iconPreviewText;
public string PresetName => _nameBox.Text.Trim();
public string PresetDescription => _descBox.Text.Trim();
public string PresetSystemPrompt => _promptBox.Text.Trim();
public string PresetTab => (_tabCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Chat";
public string PresetColor => _selectedColor;
public string PresetSymbol => _selectedSymbol;
// 사전 정의 색상 팔레트
private static readonly (string Label, string Hex)[] Colors =
{
("인디고", "#6366F1"),
("블루", "#3B82F6"),
("티일", "#14B8A6"),
("그린", "#22C55E"),
("옐로우", "#EAB308"),
("오렌지", "#F97316"),
("레드", "#EF4444"),
("핑크", "#EC4899"),
("퍼플", "#A855F7"),
("슬레이트", "#64748B"),
};
// 아이콘 셋트 — (카테고리, 아이콘 목록)
private static readonly (string Category, (string Label, string Symbol)[] Icons)[] IconSets =
{
("업무", new[]
{
("일반", "\uE771"), ("문서", "\uE8A5"), ("메일", "\uE715"), ("캘린더", "\uE787"),
("차트", "\uE9D9"), ("발표", "\uE7F4"), ("계산기", "\uE8EF"), ("메모", "\uE70B"),
}),
("기술", new[]
{
("코드", "\uE943"), ("설정", "\uE713"), ("데이터", "\uEA86"), ("검색", "\uE721"),
("보안", "\uE72E"), ("서버", "\uE839"), ("버그", "\uEBE8"), ("배포", "\uE7F8"),
}),
("커뮤니케이션", new[]
{
("대화", "\uE8BD"), ("사람", "\uE77B"), ("팀", "\uE716"), ("전화", "\uE717"),
("알림", "\uEA8F"), ("공유", "\uE72D"), ("피드백", "\uE939"), ("질문", "\uE897"),
}),
("콘텐츠", new[]
{
("사진", "\uE722"), ("동영상", "\uE714"), ("음악", "\uE8D6"), ("글쓰기", "\uE70F"),
("책", "\uE82D"), ("웹", "\uE774"), ("디자인", "\uE790"), ("다운로드", "\uE896"),
}),
("분석", new[]
{
("인사이트", "\uE9D9"), ("대시보드", "\uE809"), ("리포트", "\uE9F9"),("트렌드", "\uE8A1"),
("필터", "\uE71C"), ("목표", "\uE7C1"), ("비교", "\uE8FD"), ("측정", "\uE7C8"),
}),
};
public CustomPresetDialog(
string existingName = "",
string existingDesc = "",
string existingPrompt = "",
string existingColor = "#6366F1",
string existingSymbol = "\uE713",
string existingTab = "Chat")
{
bool isEdit = !string.IsNullOrEmpty(existingName);
Title = isEdit ? "프리셋 편집" : "프리셋 추가";
Width = 520;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
_selectedColor = existingColor;
_selectedSymbol = existingSymbol;
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(28, 24, 28, 24),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 24, ShadowDepth = 4, Opacity = 0.3, Color = System.Windows.Media.Colors.Black,
},
};
var stack = new StackPanel();
// 헤더
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 20) };
header.Children.Add(new TextBlock
{
Text = "\uE710",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
header.Children.Add(new TextBlock
{
Text = isEdit ? "프리셋 편집" : "새 커스텀 프리셋",
FontSize = 17, FontWeight = FontWeights.Bold,
Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(header);
// ── 프리셋 이름 ──
AddLabel(stack, "프리셋 이름", primaryText);
AddHint(stack, "대화 주제 버튼에 표시될 이름입니다", secondaryText);
_nameBox = CreateTextBox(existingName, primaryText, itemBg, accentBrush, borderBrush);
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _nameBox });
// ── 설명 ──
AddSeparator(stack, borderBrush);
AddLabel(stack, "설명", primaryText);
AddHint(stack, "프리셋 카드 하단에 표시될 짧은 설명입니다", secondaryText);
_descBox = CreateTextBox(existingDesc, primaryText, itemBg, accentBrush, borderBrush);
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _descBox });
// ── 아이콘 + 색상 ──
AddSeparator(stack, borderBrush);
AddLabel(stack, "아이콘 & 배경색", primaryText);
var iconColorRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
// 아이콘 미리보기 (클릭하면 아이콘 선택 팝업)
_iconPreview = new Border
{
Width = 48, Height = 48,
CornerRadius = new CornerRadius(24),
Background = new SolidColorBrush(ParseColor(_selectedColor)) { Opacity = 0.2 },
Cursor = Cursors.Hand,
Margin = new Thickness(0, 0, 16, 0),
ToolTip = "아이콘 선택",
};
_iconPreviewText = new TextBlock
{
Text = _selectedSymbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 20,
Foreground = BrushFromHex(_selectedColor),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
_iconPreview.Child = _iconPreviewText;
_iconPreview.MouseLeftButtonDown += (_, _) => ShowIconPickerPopup();
iconColorRow.Children.Add(_iconPreview);
// 탭 선택
var tabStack = new StackPanel { Margin = new Thickness(0, 0, 16, 0) };
tabStack.Children.Add(new TextBlock { Text = "탭", FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 4) });
_tabCombo = new ComboBox
{
Width = 100, FontSize = 12,
Foreground = primaryText, Background = itemBg,
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
Padding = new Thickness(6, 4, 6, 4),
};
_tabCombo.Items.Add(new ComboBoxItem { Content = "Chat", Tag = "Chat", IsSelected = existingTab == "Chat" });
_tabCombo.Items.Add(new ComboBoxItem { Content = "Cowork", Tag = "Cowork", IsSelected = existingTab == "Cowork" });
tabStack.Children.Add(_tabCombo);
iconColorRow.Children.Add(tabStack);
// 색상 팔레트
var colorStack = new StackPanel();
colorStack.Children.Add(new TextBlock { Text = "배경색", FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 4) });
var colorPanel = new WrapPanel();
foreach (var (label, hex) in Colors)
{
var colorHex = hex;
var swatch = new Border
{
Width = 22, Height = 22,
CornerRadius = new CornerRadius(11),
Background = BrushFromHex(hex),
Margin = new Thickness(0, 0, 5, 4),
Cursor = Cursors.Hand,
BorderThickness = new Thickness(2),
BorderBrush = hex == _selectedColor ? primaryText : Brushes.Transparent,
ToolTip = label,
};
swatch.MouseLeftButtonDown += (s, _) =>
{
_selectedColor = colorHex;
foreach (var child in colorPanel.Children)
if (child is Border b) b.BorderBrush = Brushes.Transparent;
if (s is Border clicked) clicked.BorderBrush = primaryText;
UpdateIconPreview();
};
colorPanel.Children.Add(swatch);
}
colorStack.Children.Add(colorPanel);
iconColorRow.Children.Add(colorStack);
stack.Children.Add(iconColorRow);
// ── 시스템 프롬프트 ──
AddSeparator(stack, borderBrush);
AddLabel(stack, "시스템 프롬프트", primaryText);
AddHint(stack, "AI가 이 프리셋에서 따를 지침입니다. 비워두면 기본 프롬프트가 사용됩니다.", secondaryText);
_promptBox = CreateTextBox(existingPrompt, primaryText, itemBg, accentBrush, borderBrush, multiline: true);
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _promptBox });
// 글자 수
var charCount = new TextBlock
{
FontSize = 10.5, Foreground = secondaryText,
Margin = new Thickness(0, 6, 0, 0),
HorizontalAlignment = HorizontalAlignment.Right,
};
UpdateCharCount(charCount);
_promptBox.TextChanged += (_, _) => UpdateCharCount(charCount);
stack.Children.Add(charCount);
// ── 버튼 바 ──
var btnBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 20, 0, 0),
};
var cancelBtn = new Button
{
Content = "취소", Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Margin = new Thickness(0, 0, 10, 0),
Background = Brushes.Transparent, Foreground = secondaryText,
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
Cursor = Cursors.Hand, FontSize = 13,
};
cancelBtn.Click += (_, _) => { DialogResult = false; Close(); };
btnBar.Children.Add(cancelBtn);
var okBtn = new Button
{
Content = isEdit ? "저장" : "추가", Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Background = accentBrush, Foreground = Brushes.White,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, FontSize = 13, FontWeight = FontWeights.SemiBold,
};
okBtn.Click += (_, _) =>
{
if (string.IsNullOrWhiteSpace(_nameBox.Text))
{
CustomMessageBox.Show("프리셋 이름을 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_nameBox.Focus();
return;
}
DialogResult = true;
Close();
};
btnBar.Children.Add(okBtn);
stack.Children.Add(btnBar);
root.Child = stack;
Content = root;
KeyDown += (_, ke) => { if (ke.Key == Key.Escape) { DialogResult = false; Close(); } };
Loaded += (_, _) => { _nameBox.Focus(); _nameBox.SelectAll(); };
root.MouseLeftButtonDown += (_, me) =>
{
if (me.LeftButton == MouseButtonState.Pressed)
try { DragMove(); } catch { }
};
}
private void UpdateIconPreview()
{
if (_iconPreview == null || _iconPreviewText == null) return;
_iconPreview.Background = new SolidColorBrush(ParseColor(_selectedColor)) { Opacity = 0.2 };
_iconPreviewText.Text = _selectedSymbol;
_iconPreviewText.Foreground = BrushFromHex(_selectedColor);
}
// ── 아이콘 피커 팝업 ────────────────────────────────────────────
private void ShowIconPickerPopup()
{
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var hoverBg = Application.Current.TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
// 별도 Window로 표시 (모달 다이얼로그 위에서 Popup 충돌 방지)
var pickerWin = new Window
{
Title = "아이콘 선택",
Width = 340,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent,
};
var popupBorder = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(12),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(12),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 3, Opacity = 0.3, Color = System.Windows.Media.Colors.Black,
},
};
var mainStack = new StackPanel();
// 타이틀 + 닫기
var titleRow = new Grid();
titleRow.Children.Add(new TextBlock
{
Text = "아이콘 선택",
FontSize = 14, FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 10),
HorizontalAlignment = HorizontalAlignment.Left,
});
var closeBtn = new TextBlock
{
Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = secondaryText,
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Top,
};
closeBtn.MouseLeftButtonDown += (_, _) => pickerWin.Close();
titleRow.Children.Add(closeBtn);
mainStack.Children.Add(titleRow);
// 드래그로 창 이동
popupBorder.MouseLeftButtonDown += (_, e) => { try { pickerWin.DragMove(); } catch { } };
// 카테고리별 아이콘 그리드
foreach (var (category, icons) in IconSets)
{
mainStack.Children.Add(new TextBlock
{
Text = category,
FontSize = 11, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(0, 6, 0, 4),
});
var wrapPanel = new WrapPanel();
foreach (var (label, symbol) in icons)
{
var capturedSymbol = symbol;
var isSelected = _selectedSymbol == symbol;
var iconBtn = new Border
{
Width = 36, Height = 36,
CornerRadius = new CornerRadius(8),
Background = isSelected ? new SolidColorBrush(ParseColor(_selectedColor)) { Opacity = 0.2 } : Brushes.Transparent,
BorderBrush = isSelected ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(isSelected ? 1.5 : 0),
Cursor = Cursors.Hand,
Margin = new Thickness(0, 0, 4, 4),
ToolTip = label,
};
iconBtn.Child = new TextBlock
{
Text = symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 16,
Foreground = isSelected ? BrushFromHex(_selectedColor) : primaryText,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
iconBtn.MouseEnter += (s, _) => { if (s is Border b && !(_selectedSymbol == capturedSymbol)) b.Background = hoverBg; };
iconBtn.MouseLeave += (s, _) => { if (s is Border b && !(_selectedSymbol == capturedSymbol)) b.Background = Brushes.Transparent; };
iconBtn.MouseLeftButtonDown += (_, e) =>
{
e.Handled = true;
_selectedSymbol = capturedSymbol;
UpdateIconPreview();
pickerWin.Close();
};
wrapPanel.Children.Add(iconBtn);
}
mainStack.Children.Add(wrapPanel);
}
popupBorder.Child = mainStack;
pickerWin.Content = popupBorder;
pickerWin.ShowDialog();
}
private static void AddLabel(StackPanel parent, string text, Brush fg) =>
parent.Children.Add(new TextBlock
{
Text = text, FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = fg, Margin = new Thickness(0, 0, 0, 6),
});
private static void AddHint(StackPanel parent, string text, Brush fg) =>
parent.Children.Add(new TextBlock
{
Text = text, FontSize = 11, Foreground = fg,
Margin = new Thickness(0, 0, 0, 8),
});
private static void AddSeparator(StackPanel parent, Brush color) =>
parent.Children.Add(new Rectangle
{
Height = 1, Fill = color,
Margin = new Thickness(0, 12, 0, 12), Opacity = 0.5,
});
private static TextBox CreateTextBox(string text, Brush fg, Brush bg, Brush caret, Brush border, bool multiline = false)
{
var tb = new TextBox
{
Text = text, FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Foreground = fg, Background = bg,
CaretBrush = caret, BorderBrush = border,
BorderThickness = new Thickness(1),
};
if (multiline)
{
tb.AcceptsReturn = true;
tb.TextWrapping = TextWrapping.Wrap;
tb.MinHeight = 100;
tb.MaxHeight = 200;
tb.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
}
return tb;
}
private void UpdateCharCount(TextBlock tb) =>
tb.Text = $"{_promptBox.Text.Length}자";
private static Brush BrushFromHex(string hex)
{
try { return new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); }
catch { return Brushes.Gray; }
}
private static Color ParseColor(string hex)
{
try { return (Color)ColorConverter.ConvertFromString(hex); }
catch { return System.Windows.Media.Colors.Gray; }
}
}

View File

@@ -0,0 +1,210 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using AxCopilot.Services;
namespace AxCopilot.Views;
/// <summary>
/// 에이전트 파일 수정 시 변경 전/후 diff를 보여주는 패널.
/// DiffService의 결과를 색상 하이라이트로 렌더링하고
/// Accept/Reject 버튼으로 변경 승인/취소를 지원합니다.
/// </summary>
public class DiffViewerPanel : Border
{
private readonly string _filePath;
private readonly string _oldContent;
private readonly string _newContent;
private readonly List<DiffService.DiffLine> _diffLines;
/// <summary>사용자가 Accept를 눌렀을 때 발생합니다.</summary>
public event EventHandler? Accepted;
/// <summary>사용자가 Reject를 눌렀을 때 발생합니다.</summary>
public event EventHandler? Rejected;
public DiffViewerPanel(string filePath, string oldContent, string newContent)
{
_filePath = filePath;
_oldContent = oldContent;
_newContent = newContent;
_diffLines = DiffService.ComputeDiff(oldContent, newContent);
CornerRadius = new CornerRadius(10);
Background = new SolidColorBrush(Color.FromRgb(0xFA, 0xFA, 0xFF));
BorderBrush = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xEC));
BorderThickness = new Thickness(1);
Padding = new Thickness(0);
Margin = new Thickness(0, 8, 0, 8);
Child = BuildContent();
}
private UIElement BuildContent()
{
var mainStack = new StackPanel();
// 헤더
var header = new Border
{
Background = new SolidColorBrush(Color.FromRgb(0xF0, 0xF2, 0xFF)),
CornerRadius = new CornerRadius(10, 10, 0, 0),
Padding = new Thickness(14, 10, 14, 10),
};
var headerGrid = new Grid();
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var summary = DiffService.GetSummary(_diffLines);
var titlePanel = new StackPanel { Orientation = Orientation.Horizontal };
titlePanel.Children.Add(new TextBlock
{
Text = "\uE89A",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
titlePanel.Children.Add(new TextBlock
{
Text = System.IO.Path.GetFileName(_filePath),
FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)),
VerticalAlignment = VerticalAlignment.Center,
});
titlePanel.Children.Add(new TextBlock
{
Text = $" · {summary}",
FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0x88, 0x88, 0xAA)),
VerticalAlignment = VerticalAlignment.Center,
});
Grid.SetColumn(titlePanel, 0);
headerGrid.Children.Add(titlePanel);
// Accept / Reject 버튼
var btnPanel = new StackPanel { Orientation = Orientation.Horizontal };
var acceptBtn = CreateButton("적용", Color.FromRgb(0x10, 0x7C, 0x10), Colors.White);
acceptBtn.Click += (_, _) => Accepted?.Invoke(this, EventArgs.Empty);
btnPanel.Children.Add(acceptBtn);
var rejectBtn = CreateButton("취소", Colors.Transparent, Color.FromRgb(0xC5, 0x0F, 0x1F));
rejectBtn.Click += (_, _) => Rejected?.Invoke(this, EventArgs.Empty);
btnPanel.Children.Add(rejectBtn);
Grid.SetColumn(btnPanel, 1);
headerGrid.Children.Add(btnPanel);
header.Child = headerGrid;
mainStack.Children.Add(header);
// Diff 내용
var scrollViewer = new ScrollViewer
{
MaxHeight = 300,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
};
var diffStack = new StackPanel { Margin = new Thickness(0) };
foreach (var line in _diffLines)
{
var (bg, fg, prefix) = line.Type switch
{
DiffService.DiffType.Added => (
Color.FromRgb(0xE6, 0xFF, 0xED),
Color.FromRgb(0x16, 0x6C, 0x34),
"+"),
DiffService.DiffType.Removed => (
Color.FromRgb(0xFF, 0xEB, 0xE9),
Color.FromRgb(0xCF, 0x22, 0x2E),
"-"),
_ => (
Color.FromRgb(0xFF, 0xFF, 0xFF),
Color.FromRgb(0x66, 0x66, 0x88),
" ")
};
var lineNo = line.OldLineNo?.ToString() ?? "";
var newLineNo = line.NewLineNo?.ToString() ?? "";
var linePanel = new Border
{
Background = new SolidColorBrush(bg),
Padding = new Thickness(8, 1, 8, 1),
};
var lineGrid = new Grid();
lineGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(36) }); // old line
lineGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(36) }); // new line
lineGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16) }); // prefix
lineGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // content
var oldLineText = new TextBlock
{
Text = lineNo, FontSize = 10, FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush(Color.FromRgb(0xAA, 0xAA, 0xCC)),
HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 0, 4, 0),
};
Grid.SetColumn(oldLineText, 0);
lineGrid.Children.Add(oldLineText);
var newLineText = new TextBlock
{
Text = newLineNo, FontSize = 10, FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush(Color.FromRgb(0xAA, 0xAA, 0xCC)),
HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 0, 4, 0),
};
Grid.SetColumn(newLineText, 1);
lineGrid.Children.Add(newLineText);
var prefixText = new TextBlock
{
Text = prefix, FontSize = 11, FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush(fg), FontWeight = FontWeights.Bold,
};
Grid.SetColumn(prefixText, 2);
lineGrid.Children.Add(prefixText);
var contentText = new TextBlock
{
Text = line.Content, FontSize = 11, FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush(fg),
TextWrapping = TextWrapping.NoWrap,
};
Grid.SetColumn(contentText, 3);
lineGrid.Children.Add(contentText);
linePanel.Child = lineGrid;
diffStack.Children.Add(linePanel);
}
scrollViewer.Content = diffStack;
mainStack.Children.Add(scrollViewer);
return mainStack;
}
private static Button CreateButton(string text, Color bg, Color fg)
{
var btn = new Button { Cursor = System.Windows.Input.Cursors.Hand, Margin = new Thickness(4, 0, 0, 0) };
var template = new ControlTemplate(typeof(Button));
var bdFactory = new FrameworkElementFactory(typeof(Border));
bdFactory.SetValue(Border.BackgroundProperty, new SolidColorBrush(bg));
bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(6));
bdFactory.SetValue(Border.PaddingProperty, new Thickness(12, 5, 12, 5));
if (bg == Colors.Transparent)
{
bdFactory.SetValue(Border.BorderBrushProperty, new SolidColorBrush(fg));
bdFactory.SetValue(Border.BorderThicknessProperty, new Thickness(1));
}
var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter));
cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
bdFactory.AppendChild(cpFactory);
template.VisualTree = bdFactory;
btn.Template = template;
btn.Content = new TextBlock { Text = text, FontSize = 11, Foreground = new SolidColorBrush(fg), FontWeight = FontWeights.SemiBold };
return btn;
}
}

View File

@@ -0,0 +1,43 @@
<Window x:Class="AxCopilot.Views.DockBarWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Dock"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
Topmost="True"
ResizeMode="NoResize"
SizeToContent="WidthAndHeight"
KeyDown="Window_KeyDown">
<Grid Margin="4">
<!-- 무지개 글로우 보더 (설정 시 표시) -->
<Border x:Name="RainbowGlowBorder" CornerRadius="15" Visibility="Collapsed"
Margin="-2">
<Border.Background>
<LinearGradientBrush x:Name="RainbowBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF0000" Offset="0.0"/>
<GradientStop Color="#FF8800" Offset="0.17"/>
<GradientStop Color="#FFFF00" Offset="0.33"/>
<GradientStop Color="#00FF00" Offset="0.50"/>
<GradientStop Color="#0088FF" Offset="0.67"/>
<GradientStop Color="#4400FF" Offset="0.83"/>
<GradientStop Color="#FF0088" Offset="1.0"/>
</LinearGradientBrush>
</Border.Background>
<Border.Opacity>0.45</Border.Opacity>
<Border.Effect>
<BlurEffect Radius="4"/>
</Border.Effect>
</Border>
<!-- 메인 독 보더 -->
<Border x:Name="DockBorder" CornerRadius="14"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource AccentColor}"
BorderThickness="1"
Padding="8,5">
<StackPanel x:Name="DockContent" Orientation="Horizontal"/>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,327 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using AxCopilot.Services;
namespace AxCopilot.Views;
/// <summary>
/// 화면 하단에 고정되는 미니 독 바.
/// 설정(DockBarItems)에 따라 표시 항목이 결정됩니다.
/// 가능한 항목: launcher, clipboard, capture, agent, clock, cpu, ram, quickinput
/// </summary>
public partial class DockBarWindow : Window
{
private DispatcherTimer? _timer;
private PerformanceCounter? _cpuCounter;
private TextBlock? _cpuText;
private TextBlock? _ramText;
private TextBlock? _clockText;
private TextBox? _quickInput;
/// <summary>런처에 검색어를 전달하는 콜백.</summary>
public Action<string>? OnQuickSearch { get; set; }
/// <summary>캡처 직접 실행 콜백.</summary>
public Action? OnCapture { get; set; }
/// <summary>AX Agent 대화창 열기 콜백.</summary>
public Action? OnOpenAgent { get; set; }
// 표시 가능한 모든 아이템 정의
private static readonly (string Key, string Icon, string Tooltip)[] AllItems =
{
("launcher", "\uE721", "AX Commander"),
("clipboard", "\uE77F", "클립보드 히스토리"),
("capture", "\uE722", "화면 캡처"),
("agent", "\uE8BD", "AX Agent"),
("clock", "\uE823", "시계"),
("cpu", "\uE950", "CPU"),
("ram", "\uE7F4", "RAM"),
("quickinput", "\uE8D3", "빠른 입력"),
};
private DispatcherTimer? _glowTimer;
/// <summary>설정 저장 콜백 (위치 저장용).</summary>
public Action<double, double>? OnPositionChanged { get; set; }
public DockBarWindow()
{
InitializeComponent();
MouseLeftButtonDown += (_, e) => { if (e.LeftButton == MouseButtonState.Pressed) try { DragMove(); } catch { } };
LocationChanged += (_, _) => OnPositionChanged?.Invoke(Left, Top);
Loaded += (_, _) => PositionDock();
Closed += (_, _) => { _timer?.Stop(); _glowTimer?.Stop(); _cpuCounter?.Dispose(); };
}
/// <summary>투명도, 위치, 글로우를 설정에서 적용합니다.</summary>
public void ApplySettings(double opacity, double left, double top, bool rainbowGlow)
{
Opacity = Math.Clamp(opacity, 0.3, 1.0);
if (left >= 0 && top >= 0)
{
Left = left;
Top = top;
}
else
{
// -1이면 중앙으로 이동
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, PositionDock);
}
if (rainbowGlow)
StartRainbowGlow();
else
StopRainbowGlow();
}
private void StartRainbowGlow()
{
RainbowGlowBorder.Visibility = Visibility.Visible;
_glowTimer ??= new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) };
var startAngle = 0.0;
_glowTimer.Tick += (_, _) =>
{
startAngle += 2;
if (startAngle >= 360) startAngle -= 360;
var rad = startAngle * Math.PI / 180.0;
RainbowBrush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(rad), 0.5 + 0.5 * Math.Sin(rad));
RainbowBrush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(rad), 0.5 - 0.5 * Math.Sin(rad));
};
_glowTimer.Start();
}
private void StopRainbowGlow()
{
_glowTimer?.Stop();
RainbowGlowBorder.Visibility = Visibility.Collapsed;
}
// 기획된 고정 표시 순서
private static readonly string[] FixedOrder =
{ "launcher", "clipboard", "capture", "agent", "clock", "cpu", "ram", "quickinput" };
/// <summary>설정에서 표시 항목 목록을 받아 독 바를 빌드합니다. 표시 순서는 기획 순서 고정.</summary>
public void BuildFromSettings(List<string> itemKeys)
{
DockContent.Children.Clear();
_cpuText = null; _ramText = null; _clockText = null; _quickInput = null;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
bool needTimer = false;
bool addedFirst = false;
// 기획 순서에서 활성화된 항목만 순서대로 표시
var enabledSet = new HashSet<string>(itemKeys, StringComparer.OrdinalIgnoreCase);
foreach (var key in FixedOrder.Where(k => enabledSet.Contains(k)))
{
if (addedFirst) AddSeparator();
addedFirst = true;
switch (key)
{
case "launcher":
AddButton("\uE721", "AX Commander", primaryText, () => OnQuickSearch?.Invoke(""));
break;
case "clipboard":
AddButton("\uE77F", "클립보드", primaryText, () => OnQuickSearch?.Invoke("#"));
break;
case "capture":
AddButton("\uE722", "캡처", primaryText, () =>
{
if (OnCapture != null) OnCapture();
else OnQuickSearch?.Invoke("cap");
});
break;
case "agent":
AddButton("\uE8BD", "AI", primaryText, () =>
{
if (OnOpenAgent != null) OnOpenAgent();
else OnQuickSearch?.Invoke("!");
});
break;
case "clock":
_clockText = new TextBlock
{
Text = DateTime.Now.ToString("HH:mm"),
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(6, 0, 6, 0),
};
DockContent.Children.Add(_clockText);
needTimer = true;
break;
case "cpu":
var cpuPanel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
cpuPanel.Children.Add(new TextBlock
{
Text = "\uE950", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0)
});
_cpuText = new TextBlock { Text = "0%", FontSize = 11, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, Width = 28, TextAlignment = TextAlignment.Right };
cpuPanel.Children.Add(_cpuText);
DockContent.Children.Add(cpuPanel);
needTimer = true;
if (_cpuCounter == null)
try { _cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total"); _cpuCounter.NextValue(); } catch { }
break;
case "ram":
var ramPanel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
ramPanel.Children.Add(new TextBlock
{
Text = "\uE7F4", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 3, 0)
});
_ramText = new TextBlock { Text = "0%", FontSize = 11, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, Width = 28, TextAlignment = TextAlignment.Right };
ramPanel.Children.Add(_ramText);
DockContent.Children.Add(ramPanel);
needTimer = true;
break;
case "quickinput":
var inputBorder = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
CornerRadius = new CornerRadius(8), Padding = new Thickness(8, 3, 8, 3),
VerticalAlignment = VerticalAlignment.Center,
};
var inputPanel = new StackPanel { Orientation = Orientation.Horizontal };
inputPanel.Children.Add(new TextBlock
{
Text = "\uE721", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0)
});
_quickInput = new TextBox
{
Width = 100, FontSize = 11,
Foreground = primaryText,
CaretBrush = accentBrush,
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
VerticalAlignment = VerticalAlignment.Center,
};
_quickInput.KeyDown += QuickInput_KeyDown;
inputPanel.Children.Add(_quickInput);
inputBorder.Child = inputPanel;
DockContent.Children.Add(inputBorder);
break;
}
}
if (needTimer)
{
_timer ??= new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_timer.Tick -= OnTick;
_timer.Tick += OnTick;
_timer.Start();
OnTick(null, EventArgs.Empty);
}
}
/// <summary>사용 가능한 모든 아이템 키와 라벨 목록을 반환합니다.</summary>
public static IReadOnlyList<(string Key, string Icon, string Tooltip)> AvailableItems => AllItems;
private void AddButton(string icon, string tooltip, Brush foreground, Action click)
{
var border = new Border
{
Width = 30, Height = 30,
CornerRadius = new CornerRadius(8),
Background = new SolidColorBrush(Color.FromArgb(0x01, 0xFF, 0xFF, 0xFF)), // 거의 투명하지만 히트 테스트 가능
Cursor = Cursors.Hand,
ToolTip = tooltip,
Margin = new Thickness(2, 0, 2, 0),
};
border.Child = new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14, Foreground = foreground,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
IsHitTestVisible = false, // 텍스트가 이벤트를 가로채지 않도록
};
border.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x22, 0xFF, 0xFF, 0xFF)); };
border.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x01, 0xFF, 0xFF, 0xFF)); };
border.MouseLeftButtonDown += (_, e) => { e.Handled = true; click(); };
DockContent.Children.Add(border);
}
private void AddSeparator()
{
DockContent.Children.Add(new Border
{
Width = 1, Height = 20, Margin = new Thickness(6, 0, 6, 0),
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
});
}
private void PositionDock()
{
var screen = SystemParameters.WorkArea;
Left = (screen.Width - ActualWidth) / 2 + screen.Left;
Top = screen.Bottom - ActualHeight - 8;
}
private void OnTick(object? sender, EventArgs e)
{
if (_clockText != null)
_clockText.Text = DateTime.Now.ToString("HH:mm");
if (_cpuText != null)
{
try { _cpuText.Text = $"{_cpuCounter?.NextValue() ?? 0:F0}%"; }
catch { _cpuText.Text = "-"; }
}
if (_ramText != null)
{
try
{
var m = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
_ramText.Text = GlobalMemoryStatusEx(ref m) ? $"{m.dwMemoryLoad}%" : "-";
}
catch { _ramText.Text = "-"; }
}
}
[DllImport("kernel32.dll")] private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
[StructLayout(LayoutKind.Sequential)]
private struct MEMORYSTATUSEX
{
public uint dwLength; public uint dwMemoryLoad;
public ulong ullTotalPhys, ullAvailPhys, ullTotalPageFile, ullAvailPageFile, ullTotalVirtual, ullAvailVirtual, ullAvailExtendedVirtual;
}
private void QuickInput_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && _quickInput != null && !string.IsNullOrWhiteSpace(_quickInput.Text))
{
OnQuickSearch?.Invoke(_quickInput.Text);
_quickInput.Text = "";
e.Handled = true;
}
else if (e.Key == Key.Escape && _quickInput != null)
{
_quickInput.Text = "";
e.Handled = true;
}
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Hide();
}
}

View File

@@ -0,0 +1,52 @@
<Window x:Class="AxCopilot.Views.EyeDropperWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
WindowStartupLocation="Manual"
Cursor="Cross"
MouseDown="Window_MouseDown"
MouseMove="Window_MouseMove"
KeyDown="Window_KeyDown">
<!-- 전체 화면 투명 오버레이 (클릭 이벤트 수신용) -->
<Canvas x:Name="RootCanvas" Background="#01000000">
<!-- 마우스 따라다니는 돋보기 -->
<Border x:Name="Magnifier"
Width="90" Height="90"
CornerRadius="45"
BorderBrush="#88FFFFFF" BorderThickness="2"
IsHitTestVisible="False"
Visibility="Collapsed">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="8" ShadowDepth="2" Opacity="0.28"/>
</Border.Effect>
<Grid>
<Ellipse x:Name="MagPreview" Width="86" Height="86"/>
<!-- 중앙 십자선 -->
<Rectangle Width="1" Height="20" Fill="#88FFFFFF" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Rectangle Width="20" Height="1" Fill="#88FFFFFF" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- HEX 코드 레이블 -->
<Border x:Name="HexLabel"
Background="#CC1A1B2E"
CornerRadius="6"
Padding="8,4"
IsHitTestVisible="False"
Visibility="Collapsed">
<TextBlock x:Name="HexLabelText"
FontSize="12"
FontFamily="Consolas"
FontWeight="Bold"
Foreground="White"/>
</Border>
</Canvas>
</Window>

View File

@@ -0,0 +1,135 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace AxCopilot.Views;
/// <summary>
/// 전체 화면 스포이드 모드. 마우스를 따라다니는 돋보기와 HEX 코드를 표시하고,
/// 클릭 시 해당 픽셀 색상을 캡처하여 ColorPickResultWindow를 표시합니다.
/// </summary>
public partial class EyeDropperWindow : Window
{
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("gdi32.dll")]
private static extern uint GetPixel(IntPtr hdc, int x, int y);
[DllImport("user32.dll")]
private static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X, Y; }
/// <summary>클릭으로 선택된 색상. null이면 취소.</summary>
public System.Drawing.Color? PickedColor { get; private set; }
public double PickX { get; private set; }
public double PickY { get; private set; }
private readonly System.Drawing.Rectangle _screenBounds;
public EyeDropperWindow()
{
InitializeComponent();
// 전체 모니터 영역
_screenBounds = GetAllScreenBounds();
Left = _screenBounds.X;
Top = _screenBounds.Y;
Width = _screenBounds.Width;
Height = _screenBounds.Height;
Loaded += (_, _) =>
{
Magnifier.Visibility = Visibility.Visible;
HexLabel.Visibility = Visibility.Visible;
};
}
private void Window_MouseMove(object sender, MouseEventArgs e)
{
var pos = e.GetPosition(RootCanvas);
var color = GetScreenPixelColor();
// 돋보기 위치 (커서 오른쪽 위)
Canvas.SetLeft(Magnifier, pos.X + 16);
Canvas.SetTop(Magnifier, pos.Y - 100);
// HEX 레이블 위치
Canvas.SetLeft(HexLabel, pos.X + 16);
Canvas.SetTop(HexLabel, pos.Y - 10);
// 돋보기 색상 미리보기
var wpfColor = System.Windows.Media.Color.FromRgb(color.R, color.G, color.B);
MagPreview.Fill = new SolidColorBrush(wpfColor);
HexLabelText.Text = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left)
{
PickedColor = GetScreenPixelColor();
GetCursorPos(out var pt);
PickX = pt.X;
PickY = pt.Y;
Close();
}
else if (e.ChangedButton == MouseButton.Right)
{
PickedColor = null;
Close();
}
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
PickedColor = null;
Close();
}
}
private static System.Drawing.Color GetScreenPixelColor()
{
GetCursorPos(out var pt);
var hdc = GetDC(IntPtr.Zero);
try
{
uint pixel = GetPixel(hdc, pt.X, pt.Y);
int r = (int)(pixel & 0xFF);
int g = (int)((pixel >> 8) & 0xFF);
int b = (int)((pixel >> 16) & 0xFF);
return System.Drawing.Color.FromArgb(r, g, b);
}
finally
{
ReleaseDC(IntPtr.Zero, hdc);
}
}
private static System.Drawing.Rectangle GetAllScreenBounds()
{
int left = 0, top = 0, right = 0, bottom = 0;
foreach (var screen in System.Windows.Forms.Screen.AllScreens)
{
var b = screen.Bounds;
left = Math.Min(left, b.Left);
top = Math.Min(top, b.Top);
right = Math.Max(right, b.Right);
bottom = Math.Max(bottom, b.Bottom);
}
return new System.Drawing.Rectangle(left, top, right - left, bottom - top);
}
}

View File

@@ -0,0 +1,78 @@
<Window x:Class="AxCopilot.Views.GuideViewerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="AX Copilot — 가이드"
Width="920" Height="740"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.3" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 타이틀바 -->
<Border Grid.Row="0" CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Grid Margin="16,0,8,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 좌측: 아이콘 + 제목 -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE736;" FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,1,10,0"/>
<TextBlock x:Name="TitleText" Text="AX Copilot 사용 가이드"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- 우측: 최소화, 최대화, 닫기 -->
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8"
MouseLeftButtonDown="MinBtn_Click"
MouseEnter="TitleBtn_Enter" MouseLeave="TitleBtn_Leave">
<TextBlock Text="&#xE921;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8"
MouseLeftButtonDown="MaxBtn_Click"
MouseEnter="TitleBtn_Enter" MouseLeave="TitleBtn_Leave">
<TextBlock x:Name="MaxBtnIcon" Text="&#xE922;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8"
MouseLeftButtonDown="CloseBtn_Click"
MouseEnter="CloseBtnEnter" MouseLeave="TitleBtn_Leave">
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- WebView2 -->
<wv2:WebView2 x:Name="GuideBrowser" Grid.Row="1"/>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,165 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
namespace AxCopilot.Views;
public partial class GuideViewerWindow : Window
{
private string? _pendingHtml;
private Uri? _pendingUri;
public GuideViewerWindow()
{
InitializeComponent();
Loaded += OnLoaded;
KeyDown += (_, e) => { if (e.Key == Key.Escape) Close(); };
StateChanged += (_, _) =>
{
MaxBtnIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE922";
};
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
var app = Application.Current as App;
var devMode = app?.SettingsService?.Settings?.Llm?.DevMode ?? false;
PrepareGuide(devMode);
try
{
// WebView2 초기화
var env = await CoreWebView2Environment.CreateAsync(
userDataFolder: Path.Combine(Path.GetTempPath(), "AxCopilot_GuideViewer"));
await GuideBrowser.EnsureCoreWebView2Async(env);
// 초기화 완료 후 대기 중인 콘텐츠 로드
if (_pendingHtml != null)
GuideBrowser.NavigateToString(_pendingHtml);
else if (_pendingUri != null)
GuideBrowser.Source = _pendingUri;
}
catch (Exception ex)
{
// WebView2 런타임 미설치 시 오류 메시지 표시
System.Diagnostics.Debug.WriteLine($"WebView2 init error: {ex.Message}");
}
}
/// <summary>개발자 모드 여부에 따라 적절한 가이드를 준비합니다.</summary>
public void PrepareGuide(bool devMode)
{
TitleText.Text = devMode ? "AX Copilot 개발자 가이드" : "AX Copilot 사용 가이드";
Title = devMode ? "AX Copilot — 개발자 가이드" : "AX Copilot — 사용 가이드";
try
{
var exeDir = Path.GetDirectoryName(Environment.ProcessPath)
?? AppContext.BaseDirectory;
var assetsDir = Path.Combine(exeDir, "Assets");
// 1차: 암호화된 가이드 파일 (.enc) 시도
var encFile = devMode
? Path.Combine(assetsDir, "guide_dev.enc")
: Path.Combine(assetsDir, "guide_user.enc");
if (File.Exists(encFile))
{
_pendingHtml = Services.GuideEncryptor.DecryptToString(encFile);
return;
}
// 2차: 평문 가이드 파일 (.htm) 폴백 (개발 환경)
var htmFile = devMode
? Path.Combine(assetsDir, "AX Copilot 개발자가이드.htm")
: Path.Combine(assetsDir, "AX Copilot 사용가이드.htm");
if (File.Exists(htmFile))
{
_pendingUri = new Uri(htmFile);
return;
}
_pendingHtml = "<html><body style='font-family:Segoe UI;padding:40px;color:#666;'>" +
"<h2>가이드를 찾을 수 없습니다</h2>" +
$"<p>경로: {encFile}</p></body></html>";
}
catch (Exception ex)
{
_pendingHtml = "<html><body style='font-family:Segoe UI;padding:40px;color:#c00;'>" +
$"<h2>가이드 로드 오류</h2><p>{ex.Message}</p></body></html>";
}
}
/// <summary>외부에서 직접 가이드를 로드합니다 (WebView2 초기화 후 호출).</summary>
public void LoadGuide(bool devMode)
{
PrepareGuide(devMode);
if (GuideBrowser.CoreWebView2 != null)
{
if (_pendingHtml != null)
GuideBrowser.NavigateToString(_pendingHtml);
else if (_pendingUri != null)
GuideBrowser.Source = _pendingUri;
}
}
// ─── 타이틀 바 ───────────────────────────────────────────────
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2)
{
ToggleMaximize();
return;
}
DragMove();
}
private void MinBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
WindowState = WindowState.Minimized;
}
private void MaxBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
ToggleMaximize();
}
private void CloseBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
Close();
}
private void ToggleMaximize()
{
WindowState = WindowState == WindowState.Maximized
? WindowState.Normal
: WindowState.Maximized;
}
private void TitleBtn_Enter(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
private void CloseBtnEnter(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = new SolidColorBrush(Color.FromArgb(0x44, 0xFF, 0x40, 0x40));
}
private void TitleBtn_Leave(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = Brushes.Transparent;
}
}

View File

@@ -0,0 +1,212 @@
<Window x:Class="AxCopilot.Views.HelpDetailWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Commander — 기능 목록"
Width="1020" Height="720"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ShowInTaskbar="False"
MouseDown="Window_MouseDown"
KeyDown="Window_KeyDown">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</Window.Resources>
<Border Margin="20"
CornerRadius="16"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource AccentColor}"
BorderThickness="1">
<Border.Effect>
<DropShadowEffect Color="{DynamicResource ShadowColor}" BlurRadius="28" ShadowDepth="5" Opacity="0.32"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ─── 헤더 ─────────────────────────────────────── -->
<Border Grid.Row="0" CornerRadius="16,16,0,0" Padding="24,18,24,14">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1A1B2E" Offset="0"/>
<GradientStop Color="#2B2D5B" Offset="0.5"/>
<GradientStop Color="#3B4ECC" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE946;"
FontFamily="Segoe MDL2 Assets"
FontSize="22"
Foreground="#6496FF"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="AX Commander — 전체 기능 목록"
FontSize="16" FontWeight="SemiBold"
Foreground="White"/>
<TextBlock x:Name="SubtitleText"
FontSize="11"
Foreground="#8899CC"/>
</StackPanel>
</StackPanel>
<Button HorizontalAlignment="Right" VerticalAlignment="Center"
Width="32" Height="32"
Background="Transparent" BorderThickness="0"
Cursor="Hand" Click="Close_Click">
<TextBlock Text="&#xE711;"
FontFamily="Segoe MDL2 Assets"
FontSize="13"
Foreground="#8899CC"/>
</Button>
</Grid>
</Border>
<!-- ─── 상단 탭 메뉴 + 검색 ── -->
<Border Grid.Row="1" Background="{DynamicResource HintBackground}" Padding="16,8">
<Grid>
<StackPanel x:Name="TopMenuBar"
Orientation="Horizontal"
HorizontalAlignment="Center"/>
<!-- 검색 박스 -->
<Border HorizontalAlignment="Right" VerticalAlignment="Center"
Background="{DynamicResource ItemBackground}" CornerRadius="8" Padding="8,4">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE721;"
FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBox x:Name="SearchBox"
Width="140" FontSize="11"
Foreground="{DynamicResource PrimaryText}"
CaretBrush="{DynamicResource AccentColor}"
Background="Transparent"
BorderThickness="0"
VerticalAlignment="Center"
TextChanged="SearchBox_TextChanged"/>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- ─── 하위 카테고리 탭 바 ────────────────────────── -->
<Border Grid.Row="2" Background="{DynamicResource SeparatorColor}" Padding="12,6">
<WrapPanel x:Name="CategoryBar"
Orientation="Horizontal"
HorizontalAlignment="Center"/>
</Border>
<!-- ─── 기능 목록 (현재 페이지) ──────────────────────── -->
<ScrollViewer Grid.Row="3"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="16,8">
<ItemsControl x:Name="ItemsHost" Margin="4,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,3" CornerRadius="10" Padding="14,10"
Background="{DynamicResource ItemBackground}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="38"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="200"/>
</Grid.ColumnDefinitions>
<!-- 심볼 아이콘 -->
<Border Grid.Column="0"
Width="32" Height="32"
CornerRadius="8"
Background="{Binding ColorBrush}"
VerticalAlignment="Center">
<TextBlock Text="{Binding Symbol}"
FontFamily="Segoe MDL2 Assets"
FontSize="14"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 카테고리 + 예약어 -->
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="10,0,8,0">
<TextBlock Text="{Binding Category}"
FontSize="10" Foreground="{DynamicResource SecondaryText}"/>
<TextBlock Text="{Binding Command}"
FontFamily="Consolas"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
</StackPanel>
<!-- 제목 + 설명 -->
<StackPanel Grid.Column="2" VerticalAlignment="Center" Margin="0,0,12,0">
<TextBlock Text="{Binding Title}"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Description}"
FontSize="11" Foreground="{DynamicResource SecondaryText}"
TextWrapping="Wrap" Margin="0,2,0,0"/>
</StackPanel>
<!-- 예시 -->
<Border Grid.Column="3"
Background="{DynamicResource HintBackground}"
CornerRadius="6"
Padding="8,4"
VerticalAlignment="Center"
Visibility="{Binding HasExample, Converter={StaticResource BoolToVis}}">
<TextBlock Text="{Binding Example}"
FontFamily="Consolas"
FontSize="10"
Foreground="{DynamicResource AccentColor}"
TextWrapping="Wrap"/>
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- ─── 푸터 (네비게이션 + 안내) ───────────────────── -->
<Border Grid.Row="4"
Background="{DynamicResource SeparatorColor}"
CornerRadius="0,0,16,16"
Padding="20,10">
<Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
<Button x:Name="PrevBtn" Content="◀ 이전"
Click="Prev_Click"
Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource AccentColor}" FontSize="12"
Cursor="Hand" Padding="8,4" Margin="0,0,8,0"/>
<TextBlock x:Name="PageIndicator"
FontSize="11" Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
<Button x:Name="NextBtn" Content="다음 ▶"
Click="Next_Click"
Background="Transparent" BorderThickness="0"
Foreground="{DynamicResource AccentColor}" FontSize="12"
Cursor="Hand" Padding="8,4" Margin="8,0,0,0"/>
</StackPanel>
<TextBlock Text="← → 페이지 이동 · ESC 닫기"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Right"
VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,673 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace AxCopilot.Views;
public partial class HelpDetailWindow : Window
{
private const string CatAll = "전체";
private const string CatPopular = "\u2B50 인기";
// ─── 상단 3탭 메뉴 ──────────────────────────────────────────────────────
private enum TopMenu { Overview, Shortcuts, Prefixes }
private static readonly (TopMenu Key, string Label, string Icon)[] TopMenus =
{
(TopMenu.Overview, "개요", "\uE946"),
(TopMenu.Shortcuts, "단축키 현황", "\uE765"),
(TopMenu.Prefixes, "예약어 현황", "\uE8F4"),
};
private TopMenu _currentTopMenu = TopMenu.Overview;
// ─── 데이터 ──────────────────────────────────────────────────────────────
private readonly List<HelpItemModel> _allItems;
private readonly List<HelpItemModel> _shortcutItems;
private readonly List<HelpItemModel> _prefixItems;
private readonly List<HelpItemModel> _overviewItems;
private readonly string _globalHotkey;
private List<string> _categories = new();
private int _currentPage;
public HelpDetailWindow(IEnumerable<HelpItemModel> items, int totalCount,
string globalHotkey = "Alt+Space")
{
InitializeComponent();
_allItems = items.ToList();
_globalHotkey = globalHotkey;
// ── 개요 항목 (전체 / 개요 / AI / 업무 보조) ───────────────────────
_overviewItems = new List<HelpItemModel>
{
// ── 개요 ────────────────────────────────────────────────────
new()
{
Category = "개요", Command = "AX Commander",
Title = "키보드 하나로 모든 업무를 제어하는 AX Commander",
Description = "단축키 한 번으로 앱 실행, 파일/폴더 검색, 계산, 클립보드 관리, 화면 캡처, 창 전환, 시스템 제어, 업무 일지, 루틴 자동화, AI 대화까지. 40개+ 명령어를 예약어로 즉시 접근.",
Symbol = "\uE946",
ColorBrush = ParseColor("#4B5EFC")
},
new()
{
Category = "개요", Command = "사용법",
Title = "예약어 + 키워드 → Enter",
Description = "특수 문자를 맨 앞에 입력하면 해당 기능 모드로 전환됩니다. 예) = 수식, # 클립보드, ? 웹검색, ! AI 대화, cap 캡처. 예약어 없이 입력하면 앱/파일/폴더 검색.",
Symbol = "\uE765",
ColorBrush = ParseColor("#0078D4")
},
new()
{
Category = "개요", Command = "데이터 보호",
Title = "모든 데이터는 내 PC에만 안전하게 저장",
Description = "설정, 클립보드, 대화 내역, 통계, 로그 등 모든 데이터가 로컬에 암호화 저장됩니다. 다른 PC에서는 열 수 없으며, 보존 기간 만료 시 자동 삭제됩니다.",
Symbol = "\uE8B7",
ColorBrush = ParseColor("#107C10")
},
// ── AI ──────────────────────────────────────────────────────
new()
{
Category = "AI", Command = "! (AX Agent)",
Title = "AX Agent — AI 어시스턴트와 대화",
Description = "AX Commander에서 ! 를 입력하면 AI 대화가 열립니다. 질문을 바로 물어보거나, 이전 대화를 이어갈 수 있습니다. 대화 주제별 분류, 검색, 사이드바 토글을 지원합니다.",
Example = "! 오늘 회의 요약해줘\n! 이메일 초안 작성해줘",
Symbol = "\uE8BD",
ColorBrush = ParseColor("#8B2FC9")
},
new()
{
Category = "AI", Command = "3탭 구조",
Title = "Chat · Cowork · Code — 용도별 3탭 구조",
Description = "Chat: 자유로운 AI 대화 — 질문, 번역, 요약, 아이디어 브레인스토밍.\n" +
"Cowork: 업무 보조 — 파일 읽기/쓰기, 문서 생성(Excel·Word·PPT·HTML), 데이터 분석, 보고서 작성.\n" +
"Code: 코딩 도우미 — 개발, 리팩터링, 코드 리뷰, 보안 점검, 테스트 작성. 개발 환경 자동 감지.",
Symbol = "\uE71B",
ColorBrush = ParseColor("#3B82F6")
},
new()
{
Category = "AI", Command = "AI 서비스",
Title = "사내 AI · 외부 AI 서비스 선택 가능",
Description = "관리자가 설정한 AI 서비스에 자동 연결됩니다. 사내 서버와 외부 서비스 중 선택할 수 있으며, 연결 정보는 암호화되어 보호됩니다.",
Symbol = "\uE968",
ColorBrush = ParseColor("#0078D4")
},
new()
{
Category = "AI", Command = "실시간 응답",
Title = "AI 응답이 실시간으로 표시",
Description = "AI의 응답이 생성되는 즉시 화면에 표시됩니다. 긴 답변도 기다리지 않고 바로 읽기 시작할 수 있습니다.",
Symbol = "\uE8AB",
ColorBrush = ParseColor("#107C10")
},
new()
{
Category = "AI", Command = "대화 보호",
Title = "모든 대화 내역이 암호화 저장",
Description = "대화 내역은 내 PC에서만 열람할 수 있도록 암호화됩니다. 다른 PC로 복사해도 열 수 없으며, 보존 기간 만료 시 자동 삭제됩니다.",
Symbol = "\uE72E",
ColorBrush = ParseColor("#C50F1F")
},
new()
{
Category = "AI", Command = "AI 규칙 설정",
Title = "관리자가 설정한 응답 규칙이 자동 적용",
Description = "관리자가 지정한 역할, 응답 톤, 업무 규칙이 모든 대화에 자동 적용됩니다. AI의 응답 스타일을 일괄 관리할 수 있습니다.",
Symbol = "\uE771",
ColorBrush = ParseColor("#D97706")
},
new()
{
Category = "AI", Command = "대화 관리",
Title = "분류 · 검색 · 타임라인",
Description = "대화를 주제별로 분류하고, 오늘/이전 타임라인으로 구분합니다. 검색과 카테고리 필터로 빠르게 찾을 수 있습니다.",
Symbol = "\uE8F1",
ColorBrush = ParseColor("#4B5EFC")
},
// ── 업무 보조 ───────────────────────────────────────────────
new()
{
Category = "업무 보조", Command = "note",
Title = "빠른 메모 저장 · 검색",
Description = "note 뒤에 내용을 입력하면 즉시 메모가 저장됩니다. 나중에 note 키워드로 검색하면 이전 메모를 다시 찾을 수 있습니다. 간단한 아이디어나 할 일을 빠르게 기록하세요.",
Example = "note 내일 보고서 마감\nnote 김부장님 연락처 확인",
Symbol = "\uE70B",
ColorBrush = ParseColor("#D97706")
},
new()
{
Category = "업무 보조", Command = "journal",
Title = "업무 일지 작성",
Description = "journal 뒤에 내용을 입력하면 날짜별 업무 일지로 자동 기록됩니다. 하루 동안의 업무 내용을 간결하게 쌓아가면 주간 보고나 회고에 활용할 수 있습니다.",
Example = "journal 배포 완료\njournal 클라이언트 미팅 30분",
Symbol = "\uE8F2",
ColorBrush = ParseColor("#0078D4")
},
new()
{
Category = "업무 보조", Command = "pipe",
Title = "클립보드 텍스트 파이프라인 변환",
Description = "클립보드에 복사된 텍스트를 여러 변환 함수로 한 번에 처리합니다. upper(대문자), lower(소문자), trim(공백 제거), wrap(줄바꿈), sort(정렬) 등을 연결할 수 있습니다.",
Example = "pipe upper | trim | wrap 80",
Symbol = "\uE8AB",
ColorBrush = ParseColor("#107C10")
},
new()
{
Category = "업무 보조", Command = "diff",
Title = "두 텍스트 비교 (Diff)",
Description = "클립보드의 두 텍스트를 비교하여 차이점을 한눈에 보여줍니다. 코드 변경 사항 확인, 문서 버전 비교 등에 유용합니다.",
Example = "diff",
Symbol = "\uE8FD",
ColorBrush = ParseColor("#6B2C91")
},
new()
{
Category = "업무 보조", Command = "encode",
Title = "인코딩 · 디코딩 변환",
Description = "Base64, URL, HTML, Unicode 등 다양한 형식으로 텍스트를 인코딩하거나 디코딩합니다. 개발자에게 특히 유용한 변환 도구입니다.",
Example = "encode base64 hello\nencode url 한글 테스트",
Symbol = "\uE943",
ColorBrush = ParseColor("#323130")
},
new()
{
Category = "업무 보조", Command = "routine",
Title = "반복 작업 루틴 자동화",
Description = "자주 하는 작업 묶음을 루틴으로 등록해 두면 한 번의 명령으로 여러 앱을 동시에 열거나 작업 환경을 구성할 수 있습니다. 출근·퇴근 루틴 등을 만들어 보세요.",
Example = "routine start 출근\nroutine list",
Symbol = "\uE8F5",
ColorBrush = ParseColor("#BE185D")
},
new()
{
Category = "업무 보조", Command = "stats",
Title = "텍스트 통계 (글자수·단어수·줄수)",
Description = "클립보드에 복사된 텍스트의 글자 수, 단어 수, 줄 수를 즉시 계산합니다. 보고서 분량 체크, SNS 글자 수 제한 확인 등에 활용하세요.",
Example = "stats",
Symbol = "\uE9D9",
ColorBrush = ParseColor("#44546A")
},
new()
{
Category = "업무 보조", Command = "json",
Title = "JSON 파싱 · 정리 · 미리보기",
Description = "클립보드에 복사된 JSON을 자동으로 파싱하고 읽기 좋은 형태로 정리합니다. 들여쓰기, 키 하이라이트, 복사 기능을 제공합니다.",
Example = "json {\"key\":\"value\"}",
Symbol = "\uE8A5",
ColorBrush = ParseColor("#4B5EFC")
},
};
// ── 단축키 항목 생성 ─────────────────────────────────────────────────
_shortcutItems = BuildShortcutItems(_globalHotkey);
// ── 예약어 항목 분류 ────────────────────────────────────────────────
_prefixItems = _allItems.ToList();
SubtitleText.Text = $"총 {totalCount}개 명령어 · 단축키 {_shortcutItems.Count}개 · 예약어 {_prefixItems.Count}개";
BuildTopMenu();
SwitchTopMenu(TopMenu.Overview);
KeyDown += OnKeyDown;
}
// ─── 단축키 항목 빌드 ─────────────────────────────────────────────────────
private static List<HelpItemModel> BuildShortcutItems(string globalHotkey = "Alt+Space")
{
var items = new List<HelpItemModel>();
// 설정에서 변경된 글로벌 단축키를 표시에 맞게 포맷 (예: "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)
{
try { return new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); }
catch { return new SolidColorBrush(Colors.Gray); }
}
private static HelpItemModel MakeShortcut(string cat, string key, string title, string desc, string symbol, string color)
{
return new HelpItemModel
{
Category = cat,
Command = key,
Title = title,
Description = desc,
Symbol = symbol,
ColorBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(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 = new FontFamily("Segoe MDL2 Assets"),
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<HelpItemModel> sourceItems)
{
var seen = new HashSet<string>();
_categories = new List<string> { 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<HelpItemModel> 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<HelpItemModel> filtered;
if (cat == CatAll)
filtered = source;
else if (cat == CatPopular)
{
var popularCmds = new HashSet<string>(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 { }
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Close();
}
}
public class HelpItemModel
{
public string Category { get; init; } = "";
public string Command { get; init; } = "";
public string Title { get; init; } = "";
public string Description { get; init; } = "";
public string Example { get; init; } = "";
public string Symbol { get; init; } = "";
public SolidColorBrush ColorBrush { get; init; } = new(Colors.Gray);
public bool HasExample => !string.IsNullOrEmpty(Example);
}

View File

@@ -0,0 +1,191 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Shapes;
namespace AxCopilot.Views;
/// <summary>테마에 맞는 커스텀 텍스트 입력 다이얼로그.</summary>
internal sealed class InputDialog : Window
{
private readonly TextBox _textBox;
public string ResponseText => _textBox.Text;
public InputDialog(string title, string prompt, string defaultValue = "", string placeholder = "")
{
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
Width = 400;
SizeToContent = SizeToContent.Height;
var bg = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x3E));
// 외부 컨테이너 (그림자 + 둥근 모서리)
var outerBorder = new Border
{
Background = bg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Margin = new Thickness(16),
Effect = new DropShadowEffect
{
BlurRadius = 24, ShadowDepth = 6, Opacity = 0.35,
Color = Colors.Black, Direction = 270,
},
};
var stack = new StackPanel { Margin = new Thickness(24, 20, 24, 20) };
// 타이틀 행 (아이콘 + 제목)
var titlePanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 16) };
titlePanel.Children.Add(new TextBlock
{
Text = "\uE8AC", // 편집 아이콘
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 16, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
titlePanel.Children.Add(new TextBlock
{
Text = title, FontSize = 15, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(titlePanel);
// 프롬프트
stack.Children.Add(new TextBlock
{
Text = prompt, FontSize = 12.5, Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
});
// 텍스트 입력 (둥근 테두리 + 플레이스홀더)
_textBox = new TextBox
{
Text = defaultValue,
FontSize = 13,
Padding = new Thickness(14, 8, 14, 8),
Foreground = primaryText,
Background = Brushes.Transparent,
CaretBrush = accentBrush,
BorderThickness = new Thickness(0),
BorderBrush = Brushes.Transparent,
};
_textBox.KeyDown += (_, e) =>
{
if (e.Key == Key.Enter) { DialogResult = true; Close(); }
if (e.Key == Key.Escape) { DialogResult = false; Close(); }
};
// 플레이스홀더 오버레이
var placeholderBlock = new TextBlock
{
Text = placeholder,
FontSize = 13,
Foreground = secondaryText,
Opacity = 0.5,
IsHitTestVisible = false,
VerticalAlignment = VerticalAlignment.Center,
Padding = new Thickness(14, 8, 14, 8),
Visibility = string.IsNullOrEmpty(defaultValue) && !string.IsNullOrEmpty(placeholder)
? Visibility.Visible : Visibility.Collapsed,
};
_textBox.TextChanged += (_, _) =>
placeholderBlock.Visibility = string.IsNullOrEmpty(_textBox.Text) ? Visibility.Visible : Visibility.Collapsed;
var inputGrid = new Grid();
inputGrid.Children.Add(_textBox);
inputGrid.Children.Add(placeholderBlock);
var inputBorder = new Border
{
CornerRadius = new CornerRadius(10),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Background = itemBg,
Child = inputGrid,
};
// 포커스 시 액센트 테두리
_textBox.GotFocus += (_, _) => inputBorder.BorderBrush = accentBrush;
_textBox.LostFocus += (_, _) => inputBorder.BorderBrush = borderBrush;
stack.Children.Add(inputBorder);
// 버튼 패널
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 18, 0, 0),
};
var cancelBtn = CreateButton("취소", Brushes.Transparent, secondaryText, borderBrush);
cancelBtn.Click += (_, _) => { DialogResult = false; Close(); };
btnPanel.Children.Add(cancelBtn);
var okBtn = CreateButton("확인", accentBrush, Brushes.White, null);
okBtn.Click += (_, _) => { DialogResult = true; Close(); };
btnPanel.Children.Add(okBtn);
stack.Children.Add(btnPanel);
outerBorder.Child = stack;
Content = outerBorder;
// 드래그 이동 (제목 영역)
titlePanel.MouseLeftButtonDown += (_, e) => { if (e.ClickCount == 1) DragMove(); };
Loaded += (_, _) => { _textBox.Focus(); _textBox.SelectAll(); };
}
private static Button CreateButton(string text, Brush background, Brush foreground, Brush? borderBrush)
{
var btn = new Button
{
Cursor = Cursors.Hand,
Margin = new Thickness(0, 0, 0, 0),
};
if (text == "취소") btn.Margin = new Thickness(0, 0, 8, 0);
// 커스텀 템플릿 (둥근 버튼)
var template = new ControlTemplate(typeof(Button));
var bdFactory = new FrameworkElementFactory(typeof(Border));
bdFactory.SetValue(Border.BackgroundProperty, background);
bdFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(8));
bdFactory.SetValue(Border.PaddingProperty, new Thickness(20, 8, 20, 8));
bdFactory.Name = "Bd";
if (borderBrush != null)
{
bdFactory.SetValue(Border.BorderBrushProperty, borderBrush);
bdFactory.SetValue(Border.BorderThicknessProperty, new Thickness(1));
}
var cpFactory = new FrameworkElementFactory(typeof(ContentPresenter));
cpFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
cpFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
bdFactory.AppendChild(cpFactory);
template.VisualTree = bdFactory;
var hoverTrigger = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true };
hoverTrigger.Setters.Add(new Setter(UIElement.OpacityProperty, 0.85));
template.Triggers.Add(hoverTrigger);
btn.Template = template;
btn.Content = new TextBlock
{
Text = text, FontSize = 13, Foreground = foreground,
};
return btn;
}
}

View File

@@ -0,0 +1,87 @@
<Window x:Class="AxCopilot.Views.LargeTypeWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
WindowState="Maximized"
Topmost="True"
ShowInTaskbar="False"
Focusable="True"
KeyDown="Window_KeyDown"
MouseDown="Window_MouseDown"
Loaded="Window_Loaded">
<!-- 반투명 오버레이 -->
<Border x:Name="Overlay" Background="#D9000000" Opacity="0">
<Border.Triggers>
<!-- 표시될 때 페이드인 -->
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="Overlay"
Storyboard.TargetProperty="Opacity"
From="0" To="1" Duration="0:0:0.18"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Border.Triggers>
<Grid>
<!-- ── 닫기 버튼 (우상단) ─── -->
<Button HorizontalAlignment="Right" VerticalAlignment="Top"
Margin="0,32,40,0" Width="40" Height="40"
Background="Transparent" BorderThickness="0"
Cursor="Hand" Click="Close_Click"
ToolTip="닫기 (Esc / 클릭)">
<TextBlock Text="&#xE711;"
FontFamily="Segoe MDL2 Assets"
FontSize="18" Foreground="#99FFFFFF"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="20"
Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#33FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<!-- ── 메인 텍스트 (중앙) ─── -->
<Viewbox Stretch="Uniform" MaxWidth="1200" MaxHeight="700"
Margin="80,80,80,120"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock x:Name="LargeText"
FontFamily="Segoe UI, Malgun Gothic"
FontWeight="SemiBold"
Foreground="White"
TextWrapping="Wrap"
TextAlignment="Center">
<TextBlock.Effect>
<DropShadowEffect Color="Black" BlurRadius="40"
ShadowDepth="0" Opacity="0.7"/>
</TextBlock.Effect>
</TextBlock>
</Viewbox>
<!-- ── 하단 힌트 ─── -->
<StackPanel VerticalAlignment="Bottom" HorizontalAlignment="Center"
Margin="0,0,0,40" Orientation="Horizontal">
<Border Background="#22FFFFFF" CornerRadius="6" Padding="10,5" Margin="0,0,8,0">
<TextBlock Text="Esc" FontSize="12" FontFamily="Segoe UI Mono, Consolas"
Foreground="#99FFFFFF" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="또는 클릭하여 닫기" FontSize="13"
Foreground="#66FFFFFF" VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,48 @@
using System.Windows;
using System.Windows.Input;
namespace AxCopilot.Views;
public partial class LargeTypeWindow : Window
{
private readonly string _text;
public LargeTypeWindow(string text)
{
_text = text;
InitializeComponent();
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
LargeText.Text = _text;
// 텍스트 길이에 따라 기본 폰트 크기 결정
// (Viewbox가 최종 크기를 조정하지만, 줄바꿈 결정을 위해 명시적 크기 필요)
LargeText.FontSize = _text.Length switch
{
<= 10 => 120,
<= 30 => 96,
<= 60 => 72,
<= 120 => 52,
_ => 38,
};
Focus();
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Close();
}
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
Close();
}
private void Close_Click(object sender, RoutedEventArgs e)
{
Close();
}
}

View File

@@ -0,0 +1,740 @@
<Window x:Class="AxCopilot.Views.LauncherWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="AX Commander"
Width="680"
SizeToContent="Height"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
WindowStartupLocation="Manual"
Loaded="Window_Loaded"
Deactivated="Window_Deactivated"
PreviewKeyDown="Window_PreviewKeyDown"
KeyDown="Window_KeyDown">
<Window.Resources>
<!-- ─── 토스트 애니메이션 ─────────────────────────────────────────── -->
<Storyboard x:Key="ToastFadeIn">
<DoubleAnimation Storyboard.TargetName="ToastOverlay"
Storyboard.TargetProperty="Opacity"
From="0" To="1" Duration="0:0:0.18">
<DoubleAnimation.EasingFunction>
<QuadraticEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
<Storyboard x:Key="ToastFadeOut">
<DoubleAnimation Storyboard.TargetName="ToastOverlay"
Storyboard.TargetProperty="Opacity"
From="1" To="0" Duration="0:0:0.28">
<DoubleAnimation.EasingFunction>
<QuadraticEase EasingMode="EaseIn"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
<!-- ─── 결과 항목 스타일 ─────────────────────────────────────────── -->
<Style x:Key="ResultItemStyle" TargetType="ListViewItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="13,8,13,8"/>
<Setter Property="Margin" Value="4,1,4,1"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Grid>
<!-- 선택 아이템 무지개 글로우 보더 (RainbowGlowBorder와 동일한 방식) -->
<Border x:Name="SelGlow"
CornerRadius="{DynamicResource ItemCornerRadius}"
Margin="-2,0"
IsHitTestVisible="False"
Visibility="Collapsed"
BorderThickness="2"
BorderBrush="{DynamicResource SelectionGlowBrush}">
<Border.Effect>
<BlurEffect Radius="8"/>
</Border.Effect>
</Border>
<!-- 선택 시 좌측 액센트 바 -->
<Border x:Name="AccentBar"
HorizontalAlignment="Left"
Width="3"
CornerRadius="1.5"
Margin="1,4,0,4"
Background="{DynamicResource AccentColor}"
Visibility="Collapsed"/>
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
CornerRadius="{DynamicResource ItemCornerRadius}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
</Border>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ItemSelectedBackground}"/>
<Setter TargetName="AccentBar" Property="Visibility" Value="Visible"/>
<!-- 무지개 글로우 보더: EnableSelectionGlow 설정에 따라 Visible/Collapsed -->
<Setter TargetName="SelGlow" Property="Visibility" Value="{DynamicResource SelectionGlowVisibility}"/>
</Trigger>
<!-- 미선택 아이템 호버 -->
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True"/>
<Condition Property="IsSelected" Value="False"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</MultiTrigger>
<!-- 선택 + 호버: 더 밝은 배경 + 강조 글로우 -->
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsSelected" Value="True"/>
<Condition Property="IsMouseOver" Value="True"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ItemSelectedHoverBackground}"/>
<Setter TargetName="Bd" Property="Effect">
<Setter.Value>
<DropShadowEffect ShadowDepth="0" BlurRadius="14" Opacity="0.4"
Color="#4B5EFC"/>
</Setter.Value>
</Setter>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ─── 스크롤바 스타일 ─────────────────────────────────────────── -->
<Style x:Key="SlimScrollThumb" TargetType="Thumb">
<Setter Property="Background" Value="{DynamicResource ScrollbarThumb}"/>
<Setter Property="Width" Value="3"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border Background="{TemplateBinding Background}" CornerRadius="2"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SlimScrollBar" TargetType="ScrollBar">
<Setter Property="Width" Value="5"/>
<Setter Property="Margin" Value="0,4,2,4"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid>
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb Style="{StaticResource SlimScrollThumb}"/>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<!-- ─── 메인 컨테이너 ──────────────────────────────────────────────── -->
<Grid Margin="24">
<!-- 그림자 전용 레이어 -->
<Border CornerRadius="{DynamicResource WindowCornerRadius}"
Background="{DynamicResource LauncherBackground}">
<Border.Effect>
<DropShadowEffect Color="{DynamicResource ShadowColor}"
BlurRadius="32" ShadowDepth="8" Opacity="0.32"/>
</Border.Effect>
</Border>
<!-- 무지개 글로우 레이어 (상시 애니메이션) -->
<Border x:Name="RainbowGlowBorder"
CornerRadius="{DynamicResource WindowCornerRadius}"
Margin="-3" Opacity="1.0"
IsHitTestVisible="False">
<Border.BorderBrush>
<LinearGradientBrush x:Name="LauncherRainbowBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF6B6B" Offset="0.0"/>
<GradientStop Color="#FECA57" Offset="0.17"/>
<GradientStop Color="#48DBFB" Offset="0.33"/>
<GradientStop Color="#FF9FF3" Offset="0.5"/>
<GradientStop Color="#54A0FF" Offset="0.67"/>
<GradientStop Color="#5F27CD" Offset="0.83"/>
<GradientStop Color="#FF6B6B" Offset="1.0"/>
</LinearGradientBrush>
</Border.BorderBrush>
<Border.BorderThickness>
<Thickness>3.5</Thickness>
</Border.BorderThickness>
<Border.Effect>
<BlurEffect Radius="10"/>
</Border.Effect>
</Border>
<!-- 콘텐츠 레이어 -->
<Border x:Name="MainBorder"
CornerRadius="{DynamicResource WindowCornerRadius}"
Background="{DynamicResource LauncherBackground}"
BorderThickness="0"
BorderBrush="Transparent">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ─── 입력 영역 ─── -->
<Grid Grid.Row="0" Margin="20,16,20,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 검색 아이콘 / Prefix 배지 -->
<Grid Grid.Column="0" Margin="0,0,10,0" VerticalAlignment="Center">
<!-- 기본 아이콘: 다이아몬드 픽셀 (prefix 없을 때) -->
<Canvas x:Name="DiamondIcon" Width="22" Height="22"
VerticalAlignment="Center" Cursor="Hand"
ClipToBounds="False"
MouseLeftButtonDown="DiamondIcon_Click"
Visibility="{Binding HasActivePrefix, Converter={StaticResource InverseBoolToVisibilityConverter}}">
<Canvas.RenderTransformOrigin>0.5,0.5</Canvas.RenderTransformOrigin>
<Canvas.RenderTransform>
<TransformGroup>
<RotateTransform x:Name="IconRotate" Angle="45"/>
<ScaleTransform x:Name="IconScale" ScaleX="1" ScaleY="1"/>
</TransformGroup>
</Canvas.RenderTransform>
<!-- 마우스 오버 시 각 픽셀 색상이 퍼져나가는 글로우 (픽셀보다 먼저 렌더링 = 아래에 배치) -->
<Rectangle x:Name="GlowBlue"
Canvas.Left="-2.5" Canvas.Top="-2.5"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#4488FF" Opacity="0">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<Rectangle x:Name="GlowGreen1"
Canvas.Left="9" Canvas.Top="-2.5"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#44DD66" Opacity="0">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<Rectangle x:Name="GlowGreen2"
Canvas.Left="-2.5" Canvas.Top="9"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#44DD66" Opacity="0">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<Rectangle x:Name="GlowRed"
Canvas.Left="9" Canvas.Top="9"
Width="14.5" Height="14.5"
RadiusX="7" RadiusY="7"
Fill="#FF4466" Opacity="0">
<Rectangle.Effect><BlurEffect Radius="9"/></Rectangle.Effect>
</Rectangle>
<!-- 파란 픽셀 (좌상→상) -->
<Rectangle x:Name="PixelBlue"
Canvas.Left="0.5" Canvas.Top="0.5"
Width="9.5" Height="9.5"
RadiusX="1.5" RadiusY="1.5"
Fill="#4488FF" Opacity="1"/>
<!-- 초록 픽셀 (우상→우) -->
<Rectangle x:Name="PixelGreen1"
Canvas.Left="12" Canvas.Top="0.5"
Width="9.5" Height="9.5"
RadiusX="1.5" RadiusY="1.5"
Fill="#44DD66" Opacity="1"/>
<!-- 초록 픽셀 (좌하→좌) -->
<Rectangle x:Name="PixelGreen2"
Canvas.Left="0.5" Canvas.Top="12"
Width="9.5" Height="9.5"
RadiusX="1.5" RadiusY="1.5"
Fill="#44DD66" Opacity="1"/>
<!-- 빨간 픽셀 (우하→하) -->
<Rectangle x:Name="PixelRed"
Canvas.Left="12" Canvas.Top="12"
Width="9.5" Height="9.5"
RadiusX="1.5" RadiusY="1.5"
Fill="#FF4466" Opacity="1"/>
<!-- 마우스 오버 → 각 픽셀 색 글로우 페이드인/아웃 -->
<Canvas.Triggers>
<EventTrigger RoutedEvent="UIElement.MouseEnter">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="GlowBlue" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen1" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen2" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowRed" Storyboard.TargetProperty="Opacity" To="0.9" Duration="0:0:0.15">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseOut"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="UIElement.MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="GlowBlue" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen1" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowGreen2" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation Storyboard.TargetName="GlowRed" Storyboard.TargetProperty="Opacity" To="0" Duration="0:0:0.22">
<DoubleAnimation.EasingFunction><QuadraticEase EasingMode="EaseIn"/></DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Canvas.Triggers>
<!-- 애니메이션은 코드비하인드에서 5가지 효과 중 랜덤 적용 -->
</Canvas>
<!-- Prefix 배지 (prefix 있을 때) -->
<Border CornerRadius="6"
Padding="7,4,7,4"
Background="{Binding ActivePrefixBrush}"
Visibility="{Binding HasActivePrefix, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding ActivePrefixSymbol}"
FontFamily="Segoe MDL2 Assets"
FontSize="11"
Foreground="White"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding ActivePrefixLabel}"
FontSize="11"
FontWeight="SemiBold"
Foreground="White"
Margin="4,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
<!-- 텍스트 입력창 -->
<TextBox x:Name="InputBox"
Grid.Column="1"
FontSize="20"
FontFamily="Segoe UI, Malgun Gothic"
Foreground="{DynamicResource PrimaryText}"
CaretBrush="{DynamicResource AccentColor}"
Background="Transparent"
BorderThickness="0"
VerticalAlignment="Center"
Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}"
TextChanged="InputBox_TextChanged"
>
<TextBox.Style>
<Style TargetType="TextBox">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Grid>
<TextBlock x:Name="Placeholder"
Text="{Binding PlaceholderText}"
FontSize="17"
Foreground="{DynamicResource PlaceholderText}"
VerticalAlignment="Center"
Margin="4,0,0,0"
IsHitTestVisible="False"
Visibility="Collapsed"/>
<ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Center"/>
</Grid>
<ControlTemplate.Triggers>
<DataTrigger Binding="{Binding Text, RelativeSource={RelativeSource Self}}" Value="">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</TextBox.Style>
</TextBox>
<!-- 로딩 인디케이터 -->
<ProgressBar Grid.Column="2"
Width="16" Height="16"
IsIndeterminate="True"
Foreground="{DynamicResource AccentColor}"
Background="Transparent"
BorderThickness="0"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}"/>
<!-- 도움말 힌트 -->
<Border Grid.Column="2"
Background="{DynamicResource HintBackground}"
CornerRadius="6"
Padding="9,4"
Visibility="{Binding IsLoading, Converter={StaticResource InverseBoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="help"
FontSize="11"
FontFamily="Segoe UI Mono, Consolas"
Foreground="{DynamicResource HintText}"
VerticalAlignment="Center"/>
<TextBlock Text="&#x21B5;"
FontSize="12"
FontFamily="Segoe UI"
Foreground="{DynamicResource HintText}"
Margin="3,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
<!-- ─── 파일 액션 모드 breadcrumb 바 ─── -->
<Border Grid.Row="1"
Padding="16,7"
Visibility="{Binding ShowActionModeBar, Converter={StaticResource BoolToVisibilityConverter}}">
<Border.Background>
<SolidColorBrush Color="{Binding Color, Source={StaticResource AccentColor}}" Opacity="0.10"/>
</Border.Background>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE76B;"
FontFamily="Segoe MDL2 Assets"
FontSize="10"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"
Margin="0,0,7,0"/>
<TextBlock Text="{Binding ActionModeBreadcrumb}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"/>
<TextBlock Text=" 에 대한 액션"
FontSize="12"
Foreground="{DynamicResource AccentColor}"
Opacity="0.65"
VerticalAlignment="Center"/>
<TextBlock Text=" · Esc로 돌아가기"
FontSize="11"
Foreground="{DynamicResource AccentColor}"
Opacity="0.45"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- ─── 구분선 (가장자리 gradient 페이드) ─── -->
<Rectangle Grid.Row="2"
Height="1"
Fill="{DynamicResource SeparatorColor}"
Visibility="{Binding Results.Count, Converter={StaticResource CountToVisibilityConverter}}">
<Rectangle.OpacityMask>
<LinearGradientBrush StartPoint="0,0.5" EndPoint="1,0.5">
<GradientStop Color="Transparent" Offset="0"/>
<GradientStop Color="White" Offset="0.08"/>
<GradientStop Color="White" Offset="0.92"/>
<GradientStop Color="Transparent" Offset="1"/>
</LinearGradientBrush>
</Rectangle.OpacityMask>
</Rectangle>
<!-- ─── 클립보드 병합 힌트 바 ─── -->
<Border Grid.Row="3"
Background="#158764B8"
Padding="16,6"
Visibility="{Binding ShowMergeHint, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE762;"
FontFamily="Segoe MDL2 Assets"
FontSize="10"
Foreground="#8764B8"
VerticalAlignment="Center"
Margin="0,0,7,0"/>
<TextBlock Text="{Binding MergeHintText}"
FontSize="11"
Foreground="#8764B8"
FontFamily="Segoe UI, Malgun Gothic"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- ─── 결과 리스트 ─── -->
<ListView x:Name="ResultList"
Grid.Row="4"
ItemsSource="{Binding Results}"
SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
Margin="6,6,6,14"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
MaxHeight="380"
VirtualizingStackPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Item"
ItemContainerStyle="{StaticResource ResultItemStyle}"
PreviewMouseLeftButtonUp="ResultList_PreviewMouseLeftButtonUp"
MouseDoubleClick="ResultList_MouseDoubleClick"
Visibility="{Binding Results.Count, Converter={StaticResource CountToVisibilityConverter}}">
<ListView.Resources>
<Style TargetType="ScrollBar" BasedOn="{StaticResource SlimScrollBar}"/>
</ListView.Resources>
<ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 번호 뱃지 (1~9, Ctrl+N으로 실행 가능) — ShowNumberBadges 설정으로 제어 -->
<TextBlock Grid.Column="0"
Text="{Binding RelativeSource={RelativeSource AncestorType=ListViewItem},
Converter={StaticResource IndexToNumberConverter}}"
FontFamily="Consolas"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
Opacity="0.55"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Width="16"
Margin="0,0,4,0"
Visibility="{Binding DataContext.ShowNumberBadges,
RelativeSource={RelativeSource AncestorType=Window},
Converter={StaticResource BoolToVisibilityConverter}}"/>
<!-- 아이콘: IconPath가 있으면 이미지, 없으면 심볼 글리프 -->
<Border Grid.Column="1"
Width="34" Height="34"
CornerRadius="8"
Margin="0,0,12,0"
VerticalAlignment="Center"
Background="{Binding Symbol, Converter={StaticResource SymbolToBackgroundConverter}}">
<!-- 외부 글로우: 마우스 오버 시 아이콘 테두리에서 빛이 퍼지는 효과 -->
<Border.Effect>
<DropShadowEffect ShadowDepth="0" BlurRadius="14" Opacity="0" Color="White"/>
</Border.Effect>
<Border.Triggers>
<EventTrigger RoutedEvent="UIElement.MouseEnter">
<BeginStoryboard>
<Storyboard>
<!-- 외부 글로우 페이드인 -->
<DoubleAnimation
Storyboard.TargetProperty="(UIElement.Effect).(DropShadowEffect.Opacity)"
To="0.65" Duration="0:0:0.14">
<DoubleAnimation.EasingFunction>
<QuadraticEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<!-- 내부 빛 발산 오버레이 페이드인 -->
<DoubleAnimation
Storyboard.TargetName="IconInnerGlow"
Storyboard.TargetProperty="Opacity"
To="1" Duration="0:0:0.14">
<DoubleAnimation.EasingFunction>
<QuadraticEase EasingMode="EaseOut"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
<EventTrigger RoutedEvent="UIElement.MouseLeave">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetProperty="(UIElement.Effect).(DropShadowEffect.Opacity)"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<QuadraticEase EasingMode="EaseIn"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
<DoubleAnimation
Storyboard.TargetName="IconInnerGlow"
Storyboard.TargetProperty="Opacity"
To="0" Duration="0:0:0.2">
<DoubleAnimation.EasingFunction>
<QuadraticEase EasingMode="EaseIn"/>
</DoubleAnimation.EasingFunction>
</DoubleAnimation>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Border.Triggers>
<Grid>
<TextBlock x:Name="SymbolText"
Text="{Binding Symbol}"
FontFamily="Segoe MDL2 Assets"
FontSize="15"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Image Source="{Binding IconPath}"
Width="22" Height="22"
RenderOptions.BitmapScalingMode="HighQuality"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding IconPath, Converter={StaticResource NullToCollapsedConverter}}"/>
<!-- 클립보드 이미지 썸네일 -->
<Image Source="{Binding Data, Converter={StaticResource ClipboardThumbnailConverter}}"
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="HighQuality"
Visibility="{Binding Data, Converter={StaticResource ClipboardHasImageConverter}}"/>
<!-- 내부 빛 발산 오버레이 (RadialGradient: 중심 → 투명 가장자리) -->
<Border x:Name="IconInnerGlow"
CornerRadius="8"
Opacity="0"
IsHitTestVisible="False">
<Border.Background>
<RadialGradientBrush GradientOrigin="0.5,0.35"
Center="0.5,0.35"
RadiusX="0.75" RadiusY="0.75">
<GradientStop Color="#70FFFFFF" Offset="0.0"/>
<GradientStop Color="#20FFFFFF" Offset="0.55"/>
<GradientStop Color="#00FFFFFF" Offset="1.0"/>
</RadialGradientBrush>
</Border.Background>
</Border>
</Grid>
</Border>
<!-- 제목 + 부제목 -->
<StackPanel Grid.Column="2" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="14"
FontWeight="Medium"
TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource PrimaryText}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListViewItem}}" Value="True">
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Subtitle}"
FontFamily="Segoe UI, Malgun Gothic"
FontSize="11"
TextTrimming="CharacterEllipsis"
Margin="0,2,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource SecondaryText}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListViewItem}}" Value="True">
<Setter Property="Foreground" Value="#E0E8FF"/>
</DataTrigger>
<DataTrigger Binding="{Binding Subtitle, Converter={StaticResource WarningSubtitleColorConverter}}" Value="{x:Static Visibility.Visible}">
<Setter Property="Foreground" Value="#E53E3E"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<!-- 우측 화살표 (선택 항목에만 표시) -->
<TextBlock Grid.Column="3"
Text="&#xE76C;"
FontFamily="Segoe MDL2 Assets"
FontSize="10"
Foreground="#AABBFF"
VerticalAlignment="Center"
Margin="8,0,2,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=ListViewItem}}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- ─── 인덱싱 상태 바 ─── -->
<TextBlock x:Name="IndexStatusText"
Grid.Row="5"
Visibility="Collapsed"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center"
Margin="0,0,0,8"
Opacity="0.7"/>
<!-- ─── 토스트 오버레이 ─── -->
<Border x:Name="ToastOverlay"
Grid.Row="4" Grid.RowSpan="2"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="0,0,0,12"
CornerRadius="8"
Padding="14,7"
Opacity="0"
Visibility="Collapsed"
IsHitTestVisible="False">
<Border.Background>
<SolidColorBrush Color="#107C10" Opacity="0.92"/>
</Border.Background>
<Border.Triggers>
<EventTrigger RoutedEvent="Border.Loaded">
<!-- 최초 로드 시 아무것도 안 함 -->
</EventTrigger>
</Border.Triggers>
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="ToastIcon"
Text="&#xE73E;"
FontFamily="Segoe MDL2 Assets"
FontSize="12" Foreground="White"
VerticalAlignment="Center"
Margin="0,0,8,0"/>
<TextBlock x:Name="ToastText"
FontSize="11" Foreground="White"
FontWeight="SemiBold"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Grid>
</Window>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,414 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace AxCopilot.Views;
/// <summary>모델 등록/편집 다이얼로그. 별칭 + 실제 모델명 입력.</summary>
internal sealed class ModelRegistrationDialog : Window
{
private readonly TextBox _aliasBox;
private readonly TextBox _modelBox;
private readonly TextBox _endpointBox;
private readonly TextBox _apiKeyBox;
// CP4D 인증 필드
private readonly ComboBox _authTypeBox;
private readonly StackPanel _cp4dPanel;
private readonly TextBox _cp4dUrlBox;
private readonly TextBox _cp4dUsernameBox;
private readonly PasswordBox _cp4dPasswordBox;
public string ModelAlias => _aliasBox.Text.Trim();
public string ModelName => _modelBox.Text.Trim();
public string Endpoint => _endpointBox.Text.Trim();
public string ApiKey => _apiKeyBox.Text.Trim();
public string AuthType => (_authTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bearer";
public string Cp4dUrl => _cp4dUrlBox.Text.Trim();
public string Cp4dUsername => _cp4dUsernameBox.Text.Trim();
public string Cp4dPassword => _cp4dPasswordBox.Password.Trim();
public ModelRegistrationDialog(string service, string existingAlias = "", string existingModel = "",
string existingEndpoint = "", string existingApiKey = "",
string existingAuthType = "bearer", string existingCp4dUrl = "",
string existingCp4dUsername = "", string existingCp4dPassword = "")
{
bool isEdit = !string.IsNullOrEmpty(existingAlias);
Title = isEdit ? "모델 편집" : "모델 추가";
Width = 420;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
// 루트 컨테이너
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(28, 24, 28, 24),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 24,
ShadowDepth = 4,
Opacity = 0.3,
Color = Colors.Black,
},
};
var stack = new StackPanel();
// 헤더
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 20) };
header.Children.Add(new TextBlock
{
Text = "\uEA86",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
header.Children.Add(new TextBlock
{
Text = isEdit ? "모델 편집" : "새 모델 등록",
FontSize = 17,
FontWeight = FontWeights.Bold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(header);
// 서비스 표시
var serviceLabel = service == "vllm" ? "vLLM" : "Ollama";
var serviceBadge = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 3, 10, 3),
Margin = new Thickness(0, 0, 0, 16),
HorizontalAlignment = HorizontalAlignment.Left,
Opacity = 0.85,
};
serviceBadge.Child = new TextBlock
{
Text = $"{serviceLabel} 서비스",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
};
stack.Children.Add(serviceBadge);
// 별칭 입력
stack.Children.Add(new TextBlock
{
Text = "별칭 (표시 이름)",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
stack.Children.Add(new TextBlock
{
Text = "채팅 화면에서 표시될 이름입니다. 예: 코드 리뷰 전용, 일반 대화",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
});
_aliasBox = new TextBox
{
Text = existingAlias,
FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText,
Background = itemBg,
CaretBrush = accentBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
};
var aliasBorder = new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _aliasBox };
stack.Children.Add(aliasBorder);
// 구분선
stack.Children.Add(new Rectangle
{
Height = 1,
Fill = borderBrush,
Margin = new Thickness(0, 16, 0, 16),
Opacity = 0.5,
});
// 모델명 입력
stack.Children.Add(new TextBlock
{
Text = "실제 모델명",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
stack.Children.Add(new TextBlock
{
Text = "서버에 배포된 실제 모델 ID (예: llama3:8b, qwen2:7b)",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
});
_modelBox = new TextBox
{
Text = existingModel,
FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText,
Background = itemBg,
CaretBrush = accentBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
};
var modelBorder = new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _modelBox };
stack.Children.Add(modelBorder);
// 구분선
stack.Children.Add(new Rectangle
{
Height = 1, Fill = borderBrush,
Margin = new Thickness(0, 16, 0, 16), Opacity = 0.5,
});
// 서버 엔드포인트 입력
stack.Children.Add(new TextBlock
{
Text = "서버 엔드포인트 (선택)",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Margin = new Thickness(0, 0, 0, 6),
});
stack.Children.Add(new TextBlock
{
Text = "이 모델 전용 서버 주소. 비워두면 기본 엔드포인트를 사용합니다.",
FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 8),
});
_endpointBox = new TextBox
{
Text = existingEndpoint,
FontSize = 13, Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText, Background = itemBg,
CaretBrush = accentBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
};
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _endpointBox });
// ── 인증 방식 선택 ──────────────────────────────────────────────────
stack.Children.Add(new TextBlock
{
Text = "인증 방식",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
});
_authTypeBox = new ComboBox
{
FontSize = 13, Padding = new Thickness(8, 6, 8, 6),
Foreground = primaryText, Background = itemBg,
BorderBrush = borderBrush, BorderThickness = new Thickness(1),
};
var bearerItem = new ComboBoxItem { Content = "Bearer 토큰 (API 키)", Tag = "bearer" };
var cp4dItem = new ComboBoxItem { Content = "CP4D (IBM Cloud Pak for Data)", Tag = "cp4d" };
_authTypeBox.Items.Add(bearerItem);
_authTypeBox.Items.Add(cp4dItem);
_authTypeBox.SelectedItem = existingAuthType == "cp4d" ? cp4dItem : bearerItem;
stack.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _authTypeBox });
// ── Bearer 인증: API 키 입력 ────────────────────────────────────────
var apiKeyPanel = new StackPanel();
apiKeyPanel.Children.Add(new TextBlock
{
Text = "API 키 (선택)",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
});
_apiKeyBox = new TextBox
{
Text = existingApiKey,
FontSize = 13, Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText, Background = itemBg,
CaretBrush = accentBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
};
apiKeyPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _apiKeyBox });
stack.Children.Add(apiKeyPanel);
// ── CP4D 인증: URL + 사용자명 + 비밀번호 ────────────────────────────
_cp4dPanel = new StackPanel { Visibility = existingAuthType == "cp4d" ? Visibility.Visible : Visibility.Collapsed };
_cp4dPanel.Children.Add(new TextBlock
{
Text = "CP4D 서버 URL",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
});
_cp4dPanel.Children.Add(new TextBlock
{
Text = "예: https://cpd-host.example.com",
FontSize = 11, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 6),
});
_cp4dUrlBox = new TextBox
{
Text = existingCp4dUrl,
FontSize = 13, Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText, Background = itemBg,
CaretBrush = accentBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
};
_cp4dPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _cp4dUrlBox });
_cp4dPanel.Children.Add(new TextBlock
{
Text = "사용자 이름",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
});
_cp4dUsernameBox = new TextBox
{
Text = existingCp4dUsername,
FontSize = 13, Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText, Background = itemBg,
CaretBrush = accentBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
};
_cp4dPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _cp4dUsernameBox });
_cp4dPanel.Children.Add(new TextBlock
{
Text = "비밀번호 / API 키",
FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = primaryText, Margin = new Thickness(0, 10, 0, 6),
});
_cp4dPasswordBox = new PasswordBox
{
Password = existingCp4dPassword,
FontSize = 13, Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText, Background = itemBg,
CaretBrush = accentBrush, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
};
_cp4dPanel.Children.Add(new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _cp4dPasswordBox });
stack.Children.Add(_cp4dPanel);
// 인증 방식 전환 시 패널 표시/숨김
_authTypeBox.SelectionChanged += (_, _) =>
{
var isCp4d = AuthType == "cp4d";
_cp4dPanel.Visibility = isCp4d ? Visibility.Visible : Visibility.Collapsed;
apiKeyPanel.Visibility = isCp4d ? Visibility.Collapsed : Visibility.Visible;
};
// 초기 상태 설정
apiKeyPanel.Visibility = existingAuthType == "cp4d" ? Visibility.Collapsed : Visibility.Visible;
// 보안 안내
var securityNote = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 12, 0, 0),
};
securityNote.Children.Add(new TextBlock
{
Text = "\uE72E",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
securityNote.Children.Add(new TextBlock
{
Text = "모델명은 AES-256으로 암호화되어 설정 파일에 저장됩니다",
FontSize = 10.5,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(securityNote);
// 버튼 바
var btnBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 24, 0, 0),
};
var cancelBtn = new Button
{
Content = "취소",
Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Margin = new Thickness(0, 0, 10, 0),
Background = Brushes.Transparent,
Foreground = secondaryText,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand,
FontSize = 13,
};
cancelBtn.Click += (_, _) => { DialogResult = false; Close(); };
btnBar.Children.Add(cancelBtn);
var okBtn = new Button
{
Content = isEdit ? "저장" : "등록",
Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Background = accentBrush,
Foreground = Brushes.White,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
};
okBtn.Click += (_, _) =>
{
if (string.IsNullOrWhiteSpace(_aliasBox.Text))
{
CustomMessageBox.Show("별칭을 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_aliasBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(_modelBox.Text))
{
CustomMessageBox.Show("모델명을 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_modelBox.Focus();
return;
}
DialogResult = true;
Close();
};
btnBar.Children.Add(okBtn);
stack.Children.Add(btnBar);
root.Child = stack;
Content = root;
// 키보드 핸들링
KeyDown += (_, ke) =>
{
if (ke.Key == Key.Escape) { DialogResult = false; Close(); }
};
Loaded += (_, _) => { _aliasBox.Focus(); _aliasBox.SelectAll(); };
// 드래그 이동
root.MouseLeftButtonDown += (_, me) =>
{
if (me.LeftButton == System.Windows.Input.MouseButtonState.Pressed)
try { DragMove(); } catch { }
};
}
}

View File

@@ -0,0 +1,951 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
namespace AxCopilot.Views;
/// <summary>
/// 에이전트 실행 계획을 상세히 보여주는 별도 창.
/// - 항목 기본 접힘 / 클릭으로 펼침
/// - 모두 열기 / 모두 닫기 툴바
/// - 사방 가장자리 드래그 리사이즈
/// - 항목 드래그로 순서 변경
/// </summary>
internal sealed class PlanViewerWindow : Window
{
// ── Win32 Resize ──
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCHITTEST = 0x0084;
private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13,
HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
private const int ResizeGrip = 12; // 사방 12px 영역에서 리사이즈 가능
private const string DragDataFormat = "PlanStepIndex";
private readonly StackPanel _stepsPanel;
private readonly ScrollViewer _scrollViewer;
private readonly StackPanel _btnPanel;
private readonly Border _statusBar;
private readonly TextBlock _statusText;
private readonly TextBlock _progressText;
// 펼침 상태 관리
private readonly HashSet<int> _expandedSteps = new();
// 드래그 상태
private int _dragSourceIndex = -1;
private Point _dragStartPoint;
private TaskCompletionSource<string?>? _tcs;
private string _planText = "";
private List<string> _steps = new();
private int _currentStep = -1;
private bool _isExecuting;
public PlanViewerWindow()
{
Width = 640;
Height = 520;
MinWidth = 480;
MinHeight = 360;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
ResizeMode = ResizeMode.CanResize; // WndProc로 직접 처리
ShowInTaskbar = false;
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(14),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Effect = new DropShadowEffect { BlurRadius = 20, ShadowDepth = 4, Opacity = 0.35, Color = Colors.Black },
};
var mainGrid = new Grid();
mainGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 0: 타이틀 바
mainGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 1: 상태 바
mainGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 2: 툴바 (모두 열기/닫기)
mainGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); // 3: 단계 목록
mainGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // 4: 하단 버튼
// ── 타이틀 바 ──
var titleBar = new Grid { Background = Brushes.Transparent, Margin = new Thickness(20, 14, 12, 0) };
titleBar.MouseLeftButtonDown += TitleBar_MouseLeftButtonDown;
var titleSp = new StackPanel { Orientation = Orientation.Horizontal };
titleSp.Children.Add(new TextBlock
{
Text = "\uE9D2", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
titleSp.Children.Add(new TextBlock
{
Text = "실행 계획", FontSize = 16, FontWeight = FontWeights.Bold,
Foreground = primaryText, VerticalAlignment = VerticalAlignment.Center,
});
titleBar.Children.Add(titleSp);
var closeBtn = new Border
{
Width = 32, Height = 32, CornerRadius = new CornerRadius(8),
Background = Brushes.Transparent, Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "\uE8BB", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = secondaryText,
HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
},
};
closeBtn.MouseEnter += (s, _) => ((Border)s).Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
closeBtn.MouseLeftButtonUp += (_, _) => Hide();
titleBar.Children.Add(closeBtn);
Grid.SetRow(titleBar, 0);
mainGrid.Children.Add(titleBar);
// ── 상태 바 (진행률) ──
_statusBar = new Border
{
Visibility = Visibility.Collapsed,
Margin = new Thickness(20, 8, 20, 0),
Padding = new Thickness(12, 6, 12, 6),
CornerRadius = new CornerRadius(8),
Background = new SolidColorBrush(Color.FromArgb(0x15,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B)),
};
var statusGrid = new Grid();
_statusText = new TextBlock { Text = "실행 중...", FontSize = 12, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center };
statusGrid.Children.Add(_statusText);
_progressText = new TextBlock
{
Text = "", FontSize = 12, Foreground = secondaryText,
HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center,
};
statusGrid.Children.Add(_progressText);
_statusBar.Child = statusGrid;
Grid.SetRow(_statusBar, 1);
mainGrid.Children.Add(_statusBar);
// ── 툴바: 모두 열기 / 모두 닫기 ──
var hoverBgTb = Application.Current.TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var toolBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(20, 6, 20, 0),
};
var expandAllBtn = MakeToolbarButton("\uE70D", "모두 열기", secondaryText, hoverBgTb);
expandAllBtn.MouseLeftButtonUp += (_, _) =>
{
for (int i = 0; i < _steps.Count; i++) _expandedSteps.Add(i);
RenderSteps();
};
toolBar.Children.Add(expandAllBtn);
var collapseAllBtn = MakeToolbarButton("\uE70E", "모두 닫기", secondaryText, hoverBgTb);
collapseAllBtn.MouseLeftButtonUp += (_, _) => { _expandedSteps.Clear(); RenderSteps(); };
toolBar.Children.Add(collapseAllBtn);
Grid.SetRow(toolBar, 2);
mainGrid.Children.Add(toolBar);
// ── 컨텐츠: 단계 목록 ──
_scrollViewer = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Margin = new Thickness(16, 6, 16, 0),
Padding = new Thickness(4),
};
_stepsPanel = new StackPanel();
_scrollViewer.Content = _stepsPanel;
Grid.SetRow(_scrollViewer, 3);
mainGrid.Children.Add(_scrollViewer);
// ── 하단 버튼 패널 ──
_btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(20, 12, 20, 16),
};
Grid.SetRow(_btnPanel, 4);
mainGrid.Children.Add(_btnPanel);
root.Child = mainGrid;
Content = root;
// Win32 Resize 훅
SourceInitialized += (_, _) =>
{
var hwnd = new WindowInteropHelper(this).Handle;
var src = HwndSource.FromHwnd(hwnd);
src?.AddHook(WndProc);
};
}
// ════════════════════════════════════════════════════════════
// 툴바 버튼 팩토리
// ════════════════════════════════════════════════════════════
private static Border MakeToolbarButton(string icon, string label, Brush fg, Brush hoverBg)
{
var btn = new Border
{
CornerRadius = new CornerRadius(6),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 4, 0),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 9, Foreground = fg,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0),
});
sp.Children.Add(new TextBlock { Text = label, FontSize = 11.5, Foreground = fg });
btn.Child = sp;
btn.MouseEnter += (s, _) => ((Border)s).Background = hoverBg;
btn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
return btn;
}
// ════════════════════════════════════════════════════════════
// 창 이동 / 리사이즈
// ════════════════════════════════════════════════════════════
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2) return;
try { DragMove(); } catch { }
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_NCHITTEST)
{
var pt = PointFromScreen(new Point(
(short)(lParam.ToInt32() & 0xFFFF),
(short)(lParam.ToInt32() >> 16)));
var w = ActualWidth;
var h = ActualHeight;
int hit = 0;
if (pt.X < ResizeGrip && pt.Y < ResizeGrip) hit = HTTOPLEFT;
else if (pt.X > w - ResizeGrip && pt.Y < ResizeGrip) hit = HTTOPRIGHT;
else if (pt.X < ResizeGrip && pt.Y > h - ResizeGrip) hit = HTBOTTOMLEFT;
else if (pt.X > w - ResizeGrip && pt.Y > h - ResizeGrip) hit = HTBOTTOMRIGHT;
else if (pt.X < ResizeGrip) hit = HTLEFT;
else if (pt.X > w - ResizeGrip) hit = HTRIGHT;
else if (pt.Y < ResizeGrip) hit = HTTOP;
else if (pt.Y > h - ResizeGrip) hit = HTBOTTOM;
if (hit != 0) { handled = true; return (IntPtr)hit; }
}
return IntPtr.Zero;
}
// ════════════════════════════════════════════════════════════
// 공개 API
// ════════════════════════════════════════════════════════════
public Task<string?> ShowPlanAsync(string planText, List<string> steps, TaskCompletionSource<string?> tcs)
{
_planText = planText;
_steps = steps;
_tcs = tcs;
_currentStep = -1;
_isExecuting = false;
_expandedSteps.Clear(); // 새 계획 표시 시 모두 접힌 상태로 시작
RenderSteps();
BuildApprovalButtons();
_statusBar.Visibility = Visibility.Collapsed;
Show();
Activate();
return tcs.Task;
}
public void SwitchToExecutionMode()
{
_isExecuting = true;
_statusBar.Visibility = Visibility.Visible;
_statusText.Text = "▶ 계획 실행 중...";
_progressText.Text = $"0 / {_steps.Count}";
BuildExecutionButtons();
}
public void UpdateCurrentStep(int stepIndex)
{
if (stepIndex < 0 || stepIndex >= _steps.Count) return;
_currentStep = stepIndex;
_progressText.Text = $"{stepIndex + 1} / {_steps.Count}";
_expandedSteps.Add(stepIndex); // 현재 실행 중인 단계는 자동 펼침
RenderSteps();
}
public void MarkComplete()
{
_isExecuting = false;
_statusText.Text = "✓ 계획 실행 완료";
_statusText.Foreground = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81));
_progressText.Text = $"{_steps.Count} / {_steps.Count}";
_currentStep = _steps.Count;
RenderSteps();
BuildCloseButton();
}
public string PlanText => _planText;
public List<string> Steps => _steps;
public string? BuildApprovedDecisionPayload(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
return null;
var normalized = _steps
.Select(step => step?.Trim() ?? "")
.Where(step => !string.IsNullOrWhiteSpace(step))
.ToList();
if (normalized.Count == 0)
return null;
var sb = new StringBuilder();
sb.AppendLine(prefix.Trim());
foreach (var step in normalized)
sb.AppendLine(step);
return sb.ToString().TrimEnd();
}
// ════════════════════════════════════════════════════════════
// 단계 목록 렌더링
// ════════════════════════════════════════════════════════════
private void RenderSteps()
{
_stepsPanel.Children.Clear();
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var hoverBg = Application.Current.TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var canEdit = !_isExecuting && _currentStep < 0; // 승인 대기 중에만 편집/순서변경 가능
for (int i = 0; i < _steps.Count; i++)
{
var step = _steps[i];
var capturedIdx = i;
var isComplete = i < _currentStep;
var isCurrent = i == _currentStep;
var isPending = i > _currentStep;
var isExpanded = _expandedSteps.Contains(i);
// ─ 카드 Border ─
var card = new Border
{
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 0, 0, 5),
Background = isCurrent
? new SolidColorBrush(Color.FromArgb(0x18,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B))
: itemBg,
BorderBrush = isCurrent ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(isCurrent ? 1.5 : 0),
AllowDrop = canEdit,
};
// 열기/닫기 토글 — 텍스트 또는 배경 클릭
card.Cursor = Cursors.Hand;
card.MouseLeftButtonUp += (s, e) =>
{
// 드래그 직후 클릭이 발생하는 경우 무시
if (e.OriginalSource is Border src && src.Tag?.ToString() == "DragHandle") return;
if (_expandedSteps.Contains(capturedIdx)) _expandedSteps.Remove(capturedIdx);
else _expandedSteps.Add(capturedIdx);
RenderSteps();
};
// ─ 카드 Grid: [drag?][badge][*text][chevron][edit?] ─
var cardGrid = new Grid();
int badgeCol = canEdit ? 1 : 0;
int textCol = canEdit ? 2 : 1;
int chevCol = canEdit ? 3 : 2;
int editCol = canEdit ? 4 : -1;
if (canEdit)
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // drag
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // badge
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // chevron
if (canEdit)
cardGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // edit btns
// ── 드래그 핸들 (편집 모드 전용) ──
if (canEdit)
{
var dimColor = Color.FromArgb(0x55, 0x80, 0x80, 0x80);
var dimBrush = new SolidColorBrush(dimColor);
var dragHandle = new Border
{
Tag = "DragHandle",
Width = 20, Cursor = Cursors.SizeAll,
Background = Brushes.Transparent,
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "\uE8FD", // Sort/Lines 아이콘 (드래그 핸들)
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = dimBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
dragHandle.MouseEnter += (s, _) =>
((TextBlock)((Border)s).Child).Foreground = secondaryText;
dragHandle.MouseLeave += (s, _) =>
((TextBlock)((Border)s).Child).Foreground = dimBrush;
// 드래그 시작 — 마우스 눌림 위치 기록
dragHandle.PreviewMouseLeftButtonDown += (s, e) =>
{
_dragSourceIndex = capturedIdx;
_dragStartPoint = e.GetPosition(_stepsPanel);
e.Handled = true; // 카드 클릭(expand) 이벤트 방지
};
// 충분히 움직이면 DragDrop 시작
dragHandle.PreviewMouseMove += (s, e) =>
{
if (_dragSourceIndex < 0 || e.LeftButton != MouseButtonState.Pressed) return;
var cur = e.GetPosition(_stepsPanel);
if (Math.Abs(cur.X - _dragStartPoint.X) > SystemParameters.MinimumHorizontalDragDistance ||
Math.Abs(cur.Y - _dragStartPoint.Y) > SystemParameters.MinimumVerticalDragDistance)
{
int idx = _dragSourceIndex;
_dragSourceIndex = -1;
DragDrop.DoDragDrop((DependencyObject)s,
new DataObject(DragDataFormat, idx), DragDropEffects.Move);
// DoDragDrop 완료 후 비주얼 정리
Dispatcher.InvokeAsync(RenderSteps);
}
};
dragHandle.PreviewMouseLeftButtonUp += (_, _) => _dragSourceIndex = -1;
Grid.SetColumn(dragHandle, 0);
cardGrid.Children.Add(dragHandle);
// ── 카드 Drop 이벤트 ──
card.DragOver += (s, e) =>
{
if (!e.Data.GetDataPresent(DragDataFormat)) return;
int src = (int)e.Data.GetData(DragDataFormat);
if (src != capturedIdx)
{
((Border)s).BorderBrush = accentBrush;
((Border)s).BorderThickness = new Thickness(1.5);
e.Effects = DragDropEffects.Move;
}
else e.Effects = DragDropEffects.None;
e.Handled = true;
};
card.DragLeave += (s, _) =>
{
bool isCurr = _currentStep == capturedIdx;
((Border)s).BorderBrush = isCurr ? accentBrush : Brushes.Transparent;
((Border)s).BorderThickness = new Thickness(isCurr ? 1.5 : 0);
};
card.Drop += (s, e) =>
{
if (!e.Data.GetDataPresent(DragDataFormat)) { e.Handled = true; return; }
int srcIdx = (int)e.Data.GetData(DragDataFormat);
int dstIdx = capturedIdx;
if (srcIdx != dstIdx && srcIdx >= 0 && srcIdx < _steps.Count)
{
var item = _steps[srcIdx];
_steps.RemoveAt(srcIdx);
// srcIdx < dstIdx 이면 제거 후 인덱스가 1 감소
int insertAt = srcIdx < dstIdx ? dstIdx - 1 : dstIdx;
_steps.Insert(insertAt, item);
_expandedSteps.Clear();
RenderSteps();
}
e.Handled = true;
};
}
// ── 상태 배지 ──
UIElement badge;
if (isComplete)
{
badge = new TextBlock
{
Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
Width = 20, TextAlignment = TextAlignment.Center,
};
}
else if (isCurrent)
{
badge = new TextBlock
{
Text = "\uE768", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
Width = 20, TextAlignment = TextAlignment.Center,
};
}
else
{
badge = new Border
{
Width = 22, Height = 22, CornerRadius = new CornerRadius(11),
Background = new SolidColorBrush(Color.FromArgb(0x25, 0x80, 0x80, 0x80)),
Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = $"{i + 1}", FontSize = 11, Foreground = secondaryText,
FontWeight = FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
}
Grid.SetColumn(badge, badgeCol);
cardGrid.Children.Add(badge);
// ── 단계 텍스트 ──
var textBlock = new TextBlock
{
Text = step,
FontSize = 13,
Foreground = isComplete ? secondaryText : primaryText,
VerticalAlignment = VerticalAlignment.Center,
Opacity = isPending && _isExecuting ? 0.6 : 1.0,
TextDecorations = isComplete ? TextDecorations.Strikethrough : null,
Margin = new Thickness(0, 0, 4, 0),
};
if (isExpanded)
{
textBlock.TextWrapping = TextWrapping.Wrap;
textBlock.TextTrimming = TextTrimming.None;
}
else
{
textBlock.TextWrapping = TextWrapping.NoWrap;
textBlock.TextTrimming = TextTrimming.CharacterEllipsis;
textBlock.ToolTip = step; // 접힌 상태: 호버 시 전체 텍스트 툴팁
}
Grid.SetColumn(textBlock, textCol);
cardGrid.Children.Add(textBlock);
// ── 펼침/접힘 Chevron ──
var chevron = new Border
{
Width = 22, Height = 22, CornerRadius = new CornerRadius(4),
Background = Brushes.Transparent, Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, canEdit ? 4 : 0, 0),
Child = new TextBlock
{
Text = isExpanded ? "\uE70E" : "\uE70D", // ChevronUp / ChevronDown
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 9,
Foreground = new SolidColorBrush(Color.FromArgb(0x70, 0x80, 0x80, 0x80)),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
chevron.MouseEnter += (s, _) => ((Border)s).Background = hoverBg;
chevron.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
chevron.MouseLeftButtonUp += (_, e) =>
{
if (_expandedSteps.Contains(capturedIdx)) _expandedSteps.Remove(capturedIdx);
else _expandedSteps.Add(capturedIdx);
RenderSteps();
e.Handled = true;
};
Grid.SetColumn(chevron, chevCol);
cardGrid.Children.Add(chevron);
// ── 편집 버튼 (위/아래/편집/삭제) ──
if (canEdit)
{
var editBtnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 0, 0),
};
if (i > 0)
{
var upBtn = CreateMiniButton("\uE70E", secondaryText, hoverBg);
upBtn.ToolTip = "위로 이동";
upBtn.MouseLeftButtonUp += (_, e) => { SwapSteps(capturedIdx, capturedIdx - 1); e.Handled = true; };
editBtnPanel.Children.Add(upBtn);
}
if (i < _steps.Count - 1)
{
var downBtn = CreateMiniButton("\uE70D", secondaryText, hoverBg);
downBtn.ToolTip = "아래로 이동";
downBtn.MouseLeftButtonUp += (_, e) => { SwapSteps(capturedIdx, capturedIdx + 1); e.Handled = true; };
editBtnPanel.Children.Add(downBtn);
}
var editBtn = CreateMiniButton("\uE70F", accentBrush, hoverBg);
editBtn.ToolTip = "편집";
editBtn.MouseLeftButtonUp += (_, e) => { EditStep(capturedIdx); e.Handled = true; };
editBtnPanel.Children.Add(editBtn);
var delBtn = CreateMiniButton("\uE74D", new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)), hoverBg);
delBtn.ToolTip = "삭제";
delBtn.MouseLeftButtonUp += (_, e) =>
{
if (_steps.Count > 1)
{
_steps.RemoveAt(capturedIdx);
_expandedSteps.Remove(capturedIdx);
RenderSteps();
}
e.Handled = true;
};
editBtnPanel.Children.Add(delBtn);
Grid.SetColumn(editBtnPanel, editCol);
cardGrid.Children.Add(editBtnPanel);
}
card.Child = cardGrid;
_stepsPanel.Children.Add(card);
}
// ── 단계 추가 버튼 (편집 모드) ──
if (canEdit)
{
var st2 = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hb2 = Application.Current.TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var addBtn = new Border
{
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 8, 14, 8),
Margin = new Thickness(0, 4, 0, 0),
Background = Brushes.Transparent,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x80)),
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand,
};
var addSp = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Center };
addSp.Children.Add(new TextBlock
{
Text = "\uE710", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = st2,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
});
addSp.Children.Add(new TextBlock { Text = "단계 추가", FontSize = 12, Foreground = st2 });
addBtn.Child = addSp;
addBtn.MouseEnter += (s, _) => ((Border)s).Background = hb2;
addBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
addBtn.MouseLeftButtonUp += (_, _) =>
{
_steps.Add("새 단계");
RenderSteps();
EditStep(_steps.Count - 1);
};
_stepsPanel.Children.Add(addBtn);
}
// 현재 단계로 자동 스크롤
if (_currentStep >= 0 && _stepsPanel.Children.Count > _currentStep)
{
_stepsPanel.UpdateLayout();
var target = (FrameworkElement)_stepsPanel.Children[Math.Min(_currentStep, _stepsPanel.Children.Count - 1)];
target.BringIntoView();
}
}
// ════════════════════════════════════════════════════════════
// 단계 편집 / 교환
// ════════════════════════════════════════════════════════════
private void SwapSteps(int a, int b)
{
if (a < 0 || b < 0 || a >= _steps.Count || b >= _steps.Count) return;
(_steps[a], _steps[b]) = (_steps[b], _steps[a]);
RenderSteps();
}
private void EditStep(int index)
{
if (index < 0 || index >= _steps.Count) return;
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
if (index >= _stepsPanel.Children.Count) return;
var editCard = new Border
{
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 0, 0, 5),
Background = itemBg,
BorderBrush = accentBrush,
BorderThickness = new Thickness(1.5),
};
var textBox = new TextBox
{
Text = _steps[index],
FontSize = 13,
Background = Brushes.Transparent,
Foreground = primaryText,
CaretBrush = primaryText,
BorderThickness = new Thickness(0),
AcceptsReturn = false,
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(4),
};
var capturedIdx = index;
textBox.KeyDown += (_, e) =>
{
if (e.Key == Key.Enter) { _steps[capturedIdx] = textBox.Text.Trim(); RenderSteps(); e.Handled = true; }
if (e.Key == Key.Escape) { RenderSteps(); e.Handled = true; }
};
textBox.LostFocus += (_, _) => { _steps[capturedIdx] = textBox.Text.Trim(); RenderSteps(); };
editCard.Child = textBox;
_stepsPanel.Children[index] = editCard;
textBox.Focus();
textBox.SelectAll();
}
// ════════════════════════════════════════════════════════════
// 하단 버튼 빌드
// ════════════════════════════════════════════════════════════
private void BuildApprovalButtons()
{
_btnPanel.Children.Clear();
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var approveBtn = CreateActionButton("\uE73E", "승인", accentBrush, Brushes.White, true);
approveBtn.MouseLeftButtonUp += (_, _) =>
{
_tcs?.TrySetResult(null);
SwitchToExecutionMode();
};
_btnPanel.Children.Add(approveBtn);
var editBtn = CreateActionButton("\uE70F", "수정 요청", accentBrush, accentBrush, false);
editBtn.MouseLeftButtonUp += (_, _) => ShowEditInput();
_btnPanel.Children.Add(editBtn);
var reconfirmBtn = CreateActionButton("\uE72C", "재확인",
Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, false);
reconfirmBtn.MouseLeftButtonUp += (_, _) =>
_tcs?.TrySetResult("계획을 다시 검토하고 더 구체적으로 수정해주세요.");
_btnPanel.Children.Add(reconfirmBtn);
var cancelBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
var cancelBtn = CreateActionButton("\uE711", "취소", cancelBrush, cancelBrush, false);
cancelBtn.MouseLeftButtonUp += (_, _) => { _tcs?.TrySetResult("취소"); Hide(); };
_btnPanel.Children.Add(cancelBtn);
}
private void BuildExecutionButtons()
{
_btnPanel.Children.Clear();
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hideBtn = CreateActionButton("\uE921", "숨기기", secondaryText,
Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White, false);
hideBtn.MouseLeftButtonUp += (_, _) => Hide();
_btnPanel.Children.Add(hideBtn);
}
private void BuildCloseButton()
{
_btnPanel.Children.Clear();
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var closeBtn = CreateActionButton("\uE73E", "닫기", accentBrush, Brushes.White, true);
closeBtn.MouseLeftButtonUp += (_, _) => Hide();
_btnPanel.Children.Add(closeBtn);
}
private void ShowEditInput()
{
var editPanel = new Border
{
Margin = new Thickness(20, 0, 20, 12),
Padding = new Thickness(12, 8, 12, 8),
CornerRadius = new CornerRadius(10),
Background = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)),
};
var editStack = new StackPanel();
editStack.Children.Add(new TextBlock
{
Text = "수정 사항을 입력하세요:",
FontSize = 11.5,
Foreground = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(0, 0, 0, 6),
});
var textBox = new TextBox
{
MinHeight = 44,
MaxHeight = 120,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 13,
Background = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E)),
Foreground = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White,
CaretBrush = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White,
BorderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 8, 10, 8),
};
editStack.Children.Add(textBox);
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var sendBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 6, 14, 6),
Margin = new Thickness(0, 8, 0, 0),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
Child = new TextBlock
{
Text = "전송", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White,
},
};
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
sendBtn.MouseLeftButtonUp += (_, _) =>
{
var feedback = textBox.Text.Trim();
if (string.IsNullOrEmpty(feedback)) return;
_tcs?.TrySetResult(feedback);
};
editStack.Children.Add(sendBtn);
editPanel.Child = editStack;
if (_btnPanel.Parent is Grid parentGrid)
{
for (int i = parentGrid.Children.Count - 1; i >= 0; i--)
{
if (parentGrid.Children[i] is Border b && b.Tag?.ToString() == "EditPanel")
parentGrid.Children.RemoveAt(i);
}
editPanel.Tag = "EditPanel";
Grid.SetRow(editPanel, 4); // row 4 = 하단 버튼 행 (toolBar 추가로 1 증가)
parentGrid.Children.Add(editPanel);
_btnPanel.Margin = new Thickness(20, 0, 20, 16);
textBox.Focus();
}
}
// ════════════════════════════════════════════════════════════
// 공통 버튼 팩토리
// ════════════════════════════════════════════════════════════
private static Border CreateMiniButton(string icon, Brush fg, Brush hoverBg)
{
var btn = new Border
{
Width = 24, Height = 24,
CornerRadius = new CornerRadius(6),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Margin = new Thickness(1, 0, 1, 0),
Child = new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10, Foreground = fg,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
btn.MouseEnter += (s, _) => ((Border)s).Background = hoverBg;
btn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
return btn;
}
private static Border CreateActionButton(string icon, string text, Brush borderColor,
Brush textColor, bool filled)
{
var color = ((SolidColorBrush)borderColor).Color;
var btn = new Border
{
CornerRadius = new CornerRadius(12),
Padding = new Thickness(16, 8, 16, 8),
Margin = new Thickness(4, 0, 4, 0),
Cursor = Cursors.Hand,
Background = filled ? borderColor
: new SolidColorBrush(Color.FromArgb(0x18, color.R, color.G, color.B)),
BorderBrush = filled ? Brushes.Transparent
: new SolidColorBrush(Color.FromArgb(0x80, color.R, color.G, color.B)),
BorderThickness = new Thickness(filled ? 0 : 1.2),
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = filled ? Brushes.White : textColor,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
});
sp.Children.Add(new TextBlock
{
Text = text, FontSize = 12.5, FontWeight = FontWeights.SemiBold,
Foreground = filled ? Brushes.White : textColor,
});
btn.Child = sp;
btn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
btn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
return btn;
}
}

View File

@@ -0,0 +1,119 @@
<Window x:Class="AxCopilot.Views.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="AX Copilot — 미리보기"
Width="860" Height="680"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip"
MinWidth="400" MinHeight="300">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.3" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 타이틀바 -->
<Border Grid.Row="0" CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Grid Margin="16,0,8,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 좌측: 아이콘 + 제목 -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE8A1;" FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,1,10,0"/>
<TextBlock x:Name="TitleText" Text="미리보기"
FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- 우측: 외부열기, 최소화, 최대화, 닫기 -->
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<!-- 외부 프로그램으로 열기 -->
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8" ToolTip="외부 프로그램으로 열기"
MouseLeftButtonDown="OpenExternalBtn_Click"
MouseEnter="TitleBtn_Enter" MouseLeave="TitleBtn_Leave">
<TextBlock Text="&#xE8A7;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8"
MouseLeftButtonDown="MinBtn_Click"
MouseEnter="TitleBtn_Enter" MouseLeave="TitleBtn_Leave">
<TextBlock Text="&#xE921;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8"
MouseLeftButtonDown="MaxBtn_Click"
MouseEnter="TitleBtn_Enter" MouseLeave="TitleBtn_Leave">
<TextBlock x:Name="MaxBtnIcon" Text="&#xE922;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border Width="40" Height="40" Background="Transparent" Cursor="Hand"
CornerRadius="8"
MouseLeftButtonDown="CloseBtn_Click"
MouseEnter="CloseBtnEnter" MouseLeave="TitleBtn_Leave">
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- 탭 바 -->
<Border Grid.Row="1" Background="#0AFFFFFF"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,0,0,1"
Padding="4,0">
<ScrollViewer HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Disabled" CanContentScroll="True">
<StackPanel x:Name="TabPanel" Orientation="Horizontal"/>
</ScrollViewer>
</Border>
<!-- 콘텐츠 영역 -->
<Grid Grid.Row="2" ClipToBounds="True">
<wv2:WebView2 x:Name="PreviewBrowser" Visibility="Collapsed"/>
<ScrollViewer x:Name="TextScroll" Visibility="Collapsed"
VerticalScrollBarVisibility="Auto" Padding="16,12">
<TextBlock x:Name="TextContent" TextWrapping="Wrap"
FontFamily="Consolas" FontSize="12"
Foreground="{DynamicResource PrimaryText}"/>
</ScrollViewer>
<DataGrid x:Name="DataGridContent" Visibility="Collapsed"
AutoGenerateColumns="True" IsReadOnly="True"
HeadersVisibility="Column" GridLinesVisibility="All"
Background="Transparent"
Foreground="{DynamicResource PrimaryText}"
BorderThickness="0" CanUserAddRows="False"
RowBackground="#0AFFFFFF" AlternatingRowBackground="#15FFFFFF"
ColumnHeaderHeight="30" RowHeight="28" FontSize="11.5"/>
<TextBlock x:Name="EmptyMessage" Text="미리보기할 파일이 없습니다"
HorizontalAlignment="Center" VerticalAlignment="Center"
FontSize="13" Foreground="{DynamicResource SecondaryText}"
Visibility="Collapsed"/>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,505 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
namespace AxCopilot.Views;
/// <summary>파일 미리보기 별도 창. WebView2 HWND airspace 문제를 근본적으로 회피합니다.</summary>
public partial class PreviewWindow : Window
{
private static PreviewWindow? _instance;
private readonly List<string> _tabs = new();
private string? _activeTab;
private bool _webViewInitialized;
private string? _selectedMood;
private static readonly string WebView2DataFolder =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "WebView2_Preview");
private static readonly HashSet<string> PreviewableExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".md", ".csv", ".txt", ".json", ".xml", ".log",
};
public PreviewWindow()
{
InitializeComponent();
Loaded += OnLoaded;
SourceInitialized += OnSourceInitialized;
KeyDown += (_, e) => { if (e.Key == Key.Escape) Close(); };
StateChanged += (_, _) =>
{
MaxBtnIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE922";
};
Closed += (_, _) => _instance = null;
}
// ─── 상하좌우 리사이즈 (WindowStyle=None 대응) ─────────────
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCHITTEST = 0x0084;
private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14;
private const int HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
private void OnSourceInitialized(object? sender, EventArgs e)
{
var hwndSource = (HwndSource)PresentationSource.FromVisual(this);
hwndSource?.AddHook(WndProc);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_NCHITTEST)
{
var pt = PointFromScreen(new Point(
(short)(lParam.ToInt32() & 0xFFFF),
(short)((lParam.ToInt32() >> 16) & 0xFFFF)));
const double grip = 8; // 리사이즈 가능 영역 (px)
var w = ActualWidth;
var h = ActualHeight;
if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; }
if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; }
if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; }
if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; }
if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; }
if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; }
if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; }
if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; }
}
return IntPtr.Zero;
}
// ─── 싱글턴 팩토리 ──────────────────────────────────────────
/// <summary>파일을 미리보기 창에 표시합니다. 이미 열려 있으면 탭을 추가합니다.</summary>
public static void ShowPreview(string filePath, string? mood = null)
{
if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
return;
var ext = Path.GetExtension(filePath).ToLowerInvariant();
if (!PreviewableExtensions.Contains(ext))
return;
Application.Current.Dispatcher.Invoke(() =>
{
if (_instance == null || !_instance.IsLoaded)
{
_instance = new PreviewWindow();
// 부모 창의 테마 리소스 전달
var mainWindow = Application.Current.MainWindow;
if (mainWindow != null)
{
foreach (var dict in mainWindow.Resources.MergedDictionaries)
_instance.Resources.MergedDictionaries.Add(dict);
}
_instance._selectedMood = mood;
_instance.Show();
}
_instance.AddTab(filePath);
_instance.Activate();
});
}
/// <summary>이미 열린 파일의 콘텐츠만 새로고침합니다. 활성 탭이 아닌 파일도 탭 목록에 있으면 활성 탭으로 전환 후 새로고침.</summary>
public static void RefreshIfOpen(string filePath)
{
if (_instance == null || !_instance.IsLoaded) return;
Application.Current.Dispatcher.Invoke(() =>
{
// 현재 활성 탭이면 즉시 새로고침
if (_instance._activeTab != null &&
string.Equals(_instance._activeTab, filePath, StringComparison.OrdinalIgnoreCase))
{
_instance.LoadContent(filePath);
return;
}
// 탭 목록에 있으면 해당 탭으로 전환 후 새로고침
var existing = _instance._tabs.FirstOrDefault(
t => string.Equals(t, filePath, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
_instance._activeTab = existing;
_instance.LoadContent(existing);
_instance.RebuildTabs();
}
});
}
/// <summary>현재 미리보기 창이 열려 있는지 반환합니다.</summary>
public static bool IsOpen => _instance != null && _instance.IsLoaded;
// ─── 초기화 ─────────────────────────────────────────────────
private async void OnLoaded(object sender, RoutedEventArgs e)
{
try
{
var env = await CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await PreviewBrowser.EnsureCoreWebView2Async(env);
_webViewInitialized = true;
// 대기 중인 콘텐츠 로드
if (_activeTab != null)
LoadContent(_activeTab);
}
catch (Exception ex)
{
Services.LogService.Warn($"PreviewWindow WebView2 초기화 실패: {ex.Message}");
}
}
// ─── 탭 관리 ────────────────────────────────────────────────
private void AddTab(string filePath)
{
if (!_tabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
_tabs.Add(filePath);
_activeTab = filePath;
RebuildTabs();
LoadContent(filePath);
}
private void CloseTab(string filePath)
{
_tabs.RemoveAll(t => string.Equals(t, filePath, StringComparison.OrdinalIgnoreCase));
if (_tabs.Count == 0)
{
Close();
return;
}
if (string.Equals(_activeTab, filePath, StringComparison.OrdinalIgnoreCase))
{
_activeTab = _tabs[^1];
LoadContent(_activeTab);
}
RebuildTabs();
}
private void RebuildTabs()
{
TabPanel.Children.Clear();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
foreach (var tabPath in _tabs)
{
var fileName = Path.GetFileName(tabPath);
var isActive = string.Equals(tabPath, _activeTab, StringComparison.OrdinalIgnoreCase);
var tabBorder = new Border
{
Background = Brushes.Transparent,
BorderBrush = isActive ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
Padding = new Thickness(10, 6, 6, 6),
Cursor = Cursors.Hand,
MaxWidth = _tabs.Count <= 3 ? 220 : (_tabs.Count <= 5 ? 160 : 110),
};
var tabContent = new StackPanel { Orientation = Orientation.Horizontal };
tabContent.Children.Add(new TextBlock
{
Text = fileName,
FontSize = 11.5,
Foreground = isActive ? primaryText : secondaryText,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = tabBorder.MaxWidth - 36,
ToolTip = tabPath,
});
// 닫기 버튼
var closePath = tabPath;
var closeBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(3),
Padding = new Thickness(4, 2, 4, 2),
Margin = new Thickness(6, 0, 0, 0),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "\uE711",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 8,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
},
};
closeBtn.MouseEnter += (s, _) =>
{
if (s is Border b)
b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0x50, 0x50));
};
closeBtn.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = Brushes.Transparent;
};
closeBtn.MouseLeftButtonUp += (_, me) =>
{
me.Handled = true;
CloseTab(closePath);
};
tabContent.Children.Add(closeBtn);
tabBorder.Child = tabContent;
// 탭 클릭 → 활성화
var clickPath = tabPath;
tabBorder.MouseLeftButtonUp += (_, me) =>
{
if (me.Handled) return;
me.Handled = true;
_activeTab = clickPath;
RebuildTabs();
LoadContent(clickPath);
};
// 호버 효과
tabBorder.MouseEnter += (s, _) =>
{
if (s is Border b && !string.Equals(clickPath, _activeTab, StringComparison.OrdinalIgnoreCase))
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
};
tabBorder.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = Brushes.Transparent;
};
TabPanel.Children.Add(tabBorder);
// 구분선
if (tabPath != _tabs[^1])
{
TabPanel.Children.Add(new Border
{
Width = 1, Height = 14,
Background = borderBrush,
Margin = new Thickness(2, 0, 2, 0),
VerticalAlignment = VerticalAlignment.Center,
});
}
}
// 타이틀 업데이트
if (_activeTab != null)
TitleText.Text = $"미리보기 — {Path.GetFileName(_activeTab)}";
}
// ─── 콘텐츠 로드 ────────────────────────────────────────────
private async void LoadContent(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
PreviewBrowser.Visibility = Visibility.Collapsed;
TextScroll.Visibility = Visibility.Collapsed;
DataGridContent.Visibility = Visibility.Collapsed;
EmptyMessage.Visibility = Visibility.Collapsed;
if (!File.Exists(filePath))
{
EmptyMessage.Text = "파일을 찾을 수 없습니다";
EmptyMessage.Visibility = Visibility.Visible;
return;
}
try
{
switch (ext)
{
case ".html":
case ".htm":
if (!_webViewInitialized) return; // OnLoaded에서 재시도
PreviewBrowser.Source = new Uri(filePath);
PreviewBrowser.Visibility = Visibility.Visible;
break;
case ".csv":
LoadCsvContent(filePath);
DataGridContent.Visibility = Visibility.Visible;
break;
case ".md":
if (!_webViewInitialized) return;
var mdText = File.ReadAllText(filePath);
if (mdText.Length > 50000) mdText = mdText[..50000];
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(
mdText, _selectedMood ?? "modern");
PreviewBrowser.NavigateToString(mdHtml);
PreviewBrowser.Visibility = Visibility.Visible;
break;
case ".txt":
case ".json":
case ".xml":
case ".log":
var text = File.ReadAllText(filePath);
if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)";
TextContent.Text = text;
TextScroll.Visibility = Visibility.Visible;
break;
default:
EmptyMessage.Text = "미리보기할 수 없는 파일 형식입니다";
EmptyMessage.Visibility = Visibility.Visible;
break;
}
}
catch (Exception ex)
{
TextContent.Text = $"미리보기 오류: {ex.Message}";
TextScroll.Visibility = Visibility.Visible;
}
await System.Threading.Tasks.Task.CompletedTask; // async 경고 방지
}
private void LoadCsvContent(string filePath)
{
var lines = File.ReadAllLines(filePath);
if (lines.Length == 0) return;
var dt = new DataTable();
var headers = ParseCsvLine(lines[0]);
foreach (var h in headers)
dt.Columns.Add(h);
var maxRows = Math.Min(lines.Length, 501);
for (int i = 1; i < maxRows; i++)
{
var vals = ParseCsvLine(lines[i]);
var row = dt.NewRow();
for (int j = 0; j < Math.Min(vals.Length, headers.Length); j++)
row[j] = vals[j];
dt.Rows.Add(row);
}
DataGridContent.ItemsSource = dt.DefaultView;
}
private static string[] ParseCsvLine(string line)
{
var fields = new List<string>();
var current = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (inQuotes)
{
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
{ current.Append('"'); i++; }
else if (c == '"') inQuotes = false;
else current.Append(c);
}
else
{
if (c == '"') inQuotes = true;
else if (c == ',') { fields.Add(current.ToString()); current.Clear(); }
else current.Append(c);
}
}
fields.Add(current.ToString());
return fields.ToArray();
}
// ─── 타이틀 바 ──────────────────────────────────────────────
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ClickCount == 2) { ToggleMaximize(); return; }
DragMove();
}
private void OpenExternalBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
if (_activeTab == null || !File.Exists(_activeTab)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _activeTab,
UseShellExecute = true,
});
}
catch (Exception ex)
{
Services.LogService.Warn($"외부 프로그램 열기 실패: {ex.Message}");
}
}
private void MinBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
WindowState = WindowState.Minimized;
}
private void MaxBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
ToggleMaximize();
}
private void CloseBtn_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
Close();
}
private void ToggleMaximize()
{
WindowState = WindowState == WindowState.Maximized
? WindowState.Normal
: WindowState.Maximized;
}
private void TitleBtn_Enter(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
private void CloseBtnEnter(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = new SolidColorBrush(Color.FromArgb(0x44, 0xFF, 0x40, 0x40));
}
private void TitleBtn_Leave(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = Brushes.Transparent;
}
}

View File

@@ -0,0 +1,244 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;
namespace AxCopilot.Views;
/// <summary>프롬프트 템플릿 추가/편집 다이얼로그.</summary>
internal sealed class PromptTemplateDialog : Window
{
private readonly TextBox _nameBox;
private readonly TextBox _contentBox;
public string TemplateName => _nameBox.Text.Trim();
public string TemplateContent => _contentBox.Text.Trim();
public PromptTemplateDialog(string existingName = "", string existingContent = "")
{
bool isEdit = !string.IsNullOrEmpty(existingName);
Title = isEdit ? "템플릿 편집" : "템플릿 추가";
Width = 480;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
// 루트 컨테이너
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(28, 24, 28, 24),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 24,
ShadowDepth = 4,
Opacity = 0.3,
Color = Colors.Black,
},
};
var stack = new StackPanel();
// 헤더
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 20) };
header.Children.Add(new TextBlock
{
Text = "\uE8A5",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
});
header.Children.Add(new TextBlock
{
Text = isEdit ? "템플릿 편집" : "새 프롬프트 템플릿",
FontSize = 17,
FontWeight = FontWeights.Bold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
stack.Children.Add(header);
// 템플릿 이름 입력
stack.Children.Add(new TextBlock
{
Text = "템플릿 이름",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
stack.Children.Add(new TextBlock
{
Text = "목록에 표시될 이름입니다. 예: 코드 리뷰 요청, 번역 요청",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
});
_nameBox = new TextBox
{
Text = existingName,
FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText,
Background = itemBg,
CaretBrush = accentBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
};
var nameBorder = new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _nameBox };
stack.Children.Add(nameBorder);
// 구분선
stack.Children.Add(new Rectangle
{
Height = 1,
Fill = borderBrush,
Margin = new Thickness(0, 16, 0, 16),
Opacity = 0.5,
});
// 프롬프트 내용 입력
stack.Children.Add(new TextBlock
{
Text = "프롬프트 내용",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
stack.Children.Add(new TextBlock
{
Text = "채팅 입력란에 자동으로 삽입될 텍스트입니다.",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
});
_contentBox = new TextBox
{
Text = existingContent,
FontSize = 13,
Padding = new Thickness(12, 8, 12, 8),
Foreground = primaryText,
Background = itemBg,
CaretBrush = accentBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
MinHeight = 100,
MaxHeight = 240,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
};
var contentBorder = new Border { CornerRadius = new CornerRadius(8), ClipToBounds = true, Child = _contentBox };
stack.Children.Add(contentBorder);
// 글자 수 표시
var charCount = new TextBlock
{
FontSize = 10.5,
Foreground = secondaryText,
Margin = new Thickness(0, 6, 0, 0),
HorizontalAlignment = HorizontalAlignment.Right,
};
UpdateCharCount(charCount);
_contentBox.TextChanged += (_, _) => UpdateCharCount(charCount);
stack.Children.Add(charCount);
// 버튼 바
var btnBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 20, 0, 0),
};
var cancelBtn = new Button
{
Content = "취소",
Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Margin = new Thickness(0, 0, 10, 0),
Background = Brushes.Transparent,
Foreground = secondaryText,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand,
FontSize = 13,
};
cancelBtn.Click += (_, _) => { DialogResult = false; Close(); };
btnBar.Children.Add(cancelBtn);
var okBtn = new Button
{
Content = isEdit ? "저장" : "추가",
Width = 80,
Padding = new Thickness(0, 8, 0, 8),
Background = accentBrush,
Foreground = Brushes.White,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
};
okBtn.Click += (_, _) =>
{
if (string.IsNullOrWhiteSpace(_nameBox.Text))
{
CustomMessageBox.Show("템플릿 이름을 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_nameBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(_contentBox.Text))
{
CustomMessageBox.Show("프롬프트 내용을 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
_contentBox.Focus();
return;
}
DialogResult = true;
Close();
};
btnBar.Children.Add(okBtn);
stack.Children.Add(btnBar);
root.Child = stack;
Content = root;
// 키보드 핸들링
KeyDown += (_, ke) =>
{
if (ke.Key == Key.Escape) { DialogResult = false; Close(); }
};
Loaded += (_, _) => { _nameBox.Focus(); _nameBox.SelectAll(); };
// 드래그 이동
root.MouseLeftButtonDown += (_, me) =>
{
if (me.LeftButton == System.Windows.Input.MouseButtonState.Pressed)
try { DragMove(); } catch { }
};
}
private void UpdateCharCount(TextBlock tb)
{
var len = _contentBox.Text.Length;
tb.Text = $"{len}자";
}
}

View File

@@ -0,0 +1,10 @@
namespace AxCopilot.Views;
public static class ProviderModelIds
{
public static string SigmoidOpus46 => string.Concat("cl", "aude-opus-4-6");
public static string SigmoidSonnet46 => string.Concat("cl", "aude-sonnet-4-6");
public static string SigmoidHaiku45_20251001 => string.Concat("cl", "aude-haiku-4-5-20251001");
public static string SigmoidSonnet45_20250929 => string.Concat("cl", "aude-sonnet-4-5-20250929");
public static string SigmoidOpus4_20250514 => string.Concat("cl", "aude-opus-4-20250514");
}

View File

@@ -0,0 +1,62 @@
<Window x:Class="AxCopilot.Views.RegionSelectWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
WindowStartupLocation="Manual"
Cursor="Cross"
MouseDown="Window_MouseDown"
MouseMove="Window_MouseMove"
MouseUp="Window_MouseUp"
KeyDown="Window_KeyDown">
<Canvas x:Name="RootCanvas">
<!-- 반투명 어두운 배경 (선택 영역 바깥) -->
<!-- 바깥 마스크: 4개의 반투명 사각형으로 구성 (성능 최적) -->
<Rectangle x:Name="OverlayTop" Fill="#88000000" Canvas.Left="0" Canvas.Top="0"/>
<Rectangle x:Name="OverlayBottom" Fill="#88000000" Canvas.Left="0"/>
<Rectangle x:Name="OverlayLeft" Fill="#88000000" Canvas.Left="0"/>
<Rectangle x:Name="OverlayRight" Fill="#88000000"/>
<!-- 선택 영역 테두리 (실선) -->
<Rectangle x:Name="SelectionBorder"
Stroke="#00D4FF"
StrokeThickness="1.5"
Fill="Transparent"
Visibility="Collapsed"/>
<!-- 가이드 텍스트 (드래그 전) -->
<Border x:Name="GuideText"
Canvas.Left="0" Canvas.Top="0"
Background="#CC1A1B2E"
CornerRadius="10"
Padding="20,12">
<StackPanel>
<TextBlock Text="캡처할 영역을 드래그하세요"
FontSize="16" FontWeight="SemiBold"
Foreground="White"
HorizontalAlignment="Center"/>
<TextBlock Text="ESC — 취소 · 마우스 버튼을 놓으면 캡처"
FontSize="12" Foreground="#88AACCFF"
HorizontalAlignment="Center"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
<!-- 크기 표시 레이블 -->
<Border x:Name="SizeLabel"
Background="#CC1A1B2E"
CornerRadius="6"
Padding="8,4"
Visibility="Collapsed">
<TextBlock x:Name="SizeLabelText"
FontSize="11"
FontFamily="Consolas"
Foreground="#00D4FF"/>
</Border>
</Canvas>
</Window>

View File

@@ -0,0 +1,192 @@
using System.Drawing;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.IO;
namespace AxCopilot.Views;
/// <summary>
/// 전체 화면 위에 반투명 오버레이를 표시하고 마우스 드래그로 캡처 영역을 선택합니다.
/// ShowDialog() 후 SelectedRect로 결과를 얻습니다.
/// </summary>
public partial class RegionSelectWindow : Window
{
public System.Drawing.Rectangle? SelectedRect { get; private set; }
private readonly System.Drawing.Rectangle _screenBounds;
private System.Windows.Point _startPoint;
private System.Windows.Point _endPoint;
private bool _isDragging;
public RegionSelectWindow(Bitmap fullScreenshot, System.Drawing.Rectangle screenBounds)
{
InitializeComponent();
_screenBounds = screenBounds;
// 창을 모든 모니터 전체에 걸쳐 표시
Left = screenBounds.X;
Top = screenBounds.Y;
Width = screenBounds.Width;
Height = screenBounds.Height;
// 전체 화면 스크린샷을 배경으로 렌더링
RootCanvas.Background = CreateFrozenBrush(fullScreenshot);
// 오버레이 초기 크기 설정
OverlayTop.Width = screenBounds.Width;
OverlayTop.Height = screenBounds.Height;
OverlayBottom.Width = screenBounds.Width;
OverlayLeft.Width = 0;
OverlayRight.Width = 0;
// 가이드 텍스트 중앙 배치 (Loaded 이후)
Loaded += (_, _) =>
{
Canvas.SetLeft(GuideText, (Width - GuideText.ActualWidth) / 2);
Canvas.SetTop(GuideText, (Height - GuideText.ActualHeight) / 2);
};
}
private static ImageBrush CreateFrozenBrush(Bitmap bmp)
{
using var ms = new MemoryStream();
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Bmp);
ms.Position = 0;
var img = new BitmapImage();
img.BeginInit();
img.StreamSource = ms;
img.CacheOption = BitmapCacheOption.OnLoad;
img.EndInit();
img.Freeze();
var brush = new ImageBrush(img) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top };
brush.Freeze();
return brush;
}
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Left) return;
_startPoint = e.GetPosition(RootCanvas);
_isDragging = true;
GuideText.Visibility = Visibility.Collapsed;
SelectionBorder.Visibility = Visibility.Visible;
SizeLabel.Visibility = Visibility.Visible;
CaptureMouse();
}
private void Window_MouseMove(object sender, MouseEventArgs e)
{
if (!_isDragging) return;
var cur = e.GetPosition(RootCanvas);
UpdateSelection(_startPoint, cur);
}
private void Window_MouseUp(object sender, MouseButtonEventArgs e)
{
if (!_isDragging || e.ChangedButton != MouseButton.Left) return;
_isDragging = false;
ReleaseMouseCapture();
var cur = e.GetPosition(RootCanvas);
_endPoint = cur;
var rect = MakeRect(_startPoint, cur);
if (rect.Width < 4 || rect.Height < 4)
{
// 너무 작으면 무시, 다시 드래그 가능
return;
}
// 선택 완료 — 화살표 키로 미세 조정 후 Enter/더블클릭으로 확정, Esc로 취소
// SizeLabel 하단에 힌트 표시
SizeLabelText.Text += " · ↑↓←→ 미세조정 · Enter 확정";
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
SelectedRect = null;
Close();
return;
}
// 드래그 완료 후 화살표 키로 선택 영역 미세 조정
if (!_isDragging && SelectionBorder.Visibility == Visibility.Visible)
{
double dx = 0, dy = 0;
bool shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
double step = shift ? 10 : 1; // Shift 누르면 10px 단위
switch (e.Key)
{
case Key.Left: dx = -step; break;
case Key.Right: dx = step; break;
case Key.Up: dy = -step; break;
case Key.Down: dy = step; break;
case Key.Enter:
// Enter 키로 현재 선택 확정
var r = MakeRect(_startPoint, _endPoint);
if (r.Width >= 4 && r.Height >= 4)
{
var dpi = VisualTreeHelper.GetDpi(this);
SelectedRect = new System.Drawing.Rectangle(
(int)(r.X * dpi.DpiScaleX) + _screenBounds.X,
(int)(r.Y * dpi.DpiScaleY) + _screenBounds.Y,
(int)(r.Width * dpi.DpiScaleX),
(int)(r.Height * dpi.DpiScaleY));
}
Close();
return;
default: return;
}
_endPoint = new System.Windows.Point(_endPoint.X + dx, _endPoint.Y + dy);
UpdateSelection(_startPoint, _endPoint);
e.Handled = true;
}
}
private void UpdateSelection(System.Windows.Point a, System.Windows.Point b)
{
var rect = MakeRect(a, b);
Canvas.SetLeft(SelectionBorder, rect.X);
Canvas.SetTop(SelectionBorder, rect.Y);
SelectionBorder.Width = rect.Width;
SelectionBorder.Height = rect.Height;
// 바깥 마스크 업데이트
OverlayTop.Width = Width;
OverlayTop.Height = rect.Y;
OverlayBottom.Width = Width;
OverlayBottom.Height = Height - rect.Y - rect.Height;
Canvas.SetTop(OverlayBottom, rect.Y + rect.Height);
OverlayLeft.Width = rect.X;
OverlayLeft.Height = rect.Height;
Canvas.SetTop(OverlayLeft, rect.Y);
OverlayRight.Width = Width - rect.X - rect.Width;
OverlayRight.Height = rect.Height;
Canvas.SetLeft(OverlayRight, rect.X + rect.Width);
Canvas.SetTop(OverlayRight, rect.Y);
// 크기 레이블
var dpiScale = VisualTreeHelper.GetDpi(this);
var pw = (int)(rect.Width * dpiScale.DpiScaleX);
var ph = (int)(rect.Height * dpiScale.DpiScaleY);
SizeLabelText.Text = $"{pw} × {ph}";
Canvas.SetLeft(SizeLabel, rect.X + rect.Width + 8);
Canvas.SetTop(SizeLabel, rect.Y + rect.Height + 4);
}
private static Rect MakeRect(System.Windows.Point a, System.Windows.Point b) =>
new(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y),
Math.Abs(b.X - a.X), Math.Abs(b.Y - a.Y));
}

View File

@@ -0,0 +1,107 @@
<Window x:Class="AxCopilot.Views.ReminderPopupWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
Width="340"
SizeToContent="Height"
WindowStartupLocation="Manual"
Focusable="False">
<Border x:Name="PopupBorder"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="14"
Padding="18,14,18,0">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="4" Opacity="0.28" Direction="270"/>
</Border.Effect>
<StackPanel>
<!-- ── 상단 헤더: 아이콘 + 근무 시간 + 닫기 버튼 ── -->
<Grid Margin="0,0,0,10">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<!-- 시계 아이콘 -->
<TextBlock Text="&#xE823;"
FontFamily="Segoe MDL2 Assets"
FontSize="13"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center"
Margin="0,0,7,0"/>
<!-- 근무 시간 -->
<TextBlock x:Name="UsageText"
FontSize="11.5"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<!-- 닫기 버튼 -->
<Button x:Name="CloseBtn"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Width="22" Height="22"
Cursor="Hand"
Click="CloseBtn_Click"
ToolTip="닫기">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bg" CornerRadius="5" Background="Transparent">
<TextBlock Text="&#xE711;"
FontFamily="Segoe MDL2 Assets"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bg" Property="Background" Value="{DynamicResource ItemHoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
<!-- ── 구분선 ── -->
<Rectangle Height="1" Fill="{DynamicResource SeparatorColor}" Margin="0,0,0,12"/>
<!-- ── 문구 텍스트 ── -->
<TextBlock x:Name="QuoteText"
Foreground="{DynamicResource PrimaryText}"
FontSize="13.5"
FontWeight="Normal"
TextWrapping="Wrap"
LineHeight="22"
Margin="0,0,0,8"/>
<!-- ── 출처 (명언일 때만 표시) ── -->
<TextBlock x:Name="AuthorText"
Foreground="{DynamicResource AccentColor}"
FontSize="11"
FontStyle="Italic"
Margin="0,0,0,4"
Visibility="Collapsed"/>
<!-- ── 카운트다운 프로그레스 바 ── -->
<ProgressBar x:Name="CountdownBar"
Height="2"
Margin="0,10,0,0"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource AccentColor}"
Minimum="0"/>
<!-- 하단 여백 -->
<Rectangle Height="10"/>
</StackPanel>
</Border>
</Window>

View File

@@ -0,0 +1,141 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Threading;
using AxCopilot.Services;
namespace AxCopilot.Views;
/// <summary>
/// 잠금 해제 시 화면 모서리에 표시되는 격려 팝업.
/// 테마는 Application.Current.Resources의 DynamicResource로 자동 적용됩니다.
/// 포커스를 빼앗지 않으며(WS_EX_NOACTIVATE), 지정된 초 후 자동으로 닫힙니다.
/// </summary>
public partial class ReminderPopupWindow : Window
{
// ─── 포커스 방지 P/Invoke (64비트 안전) ────────────────────────────────────
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
private static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int nIndex);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int nIndex, IntPtr dwNewLong);
private const int GWL_EXSTYLE = -20;
private const int WS_EX_NOACTIVATE = 0x08000000;
private const int WS_EX_TOOLWINDOW = 0x00000080;
// ─── 타이머 ───────────────────────────────────────────────────────────────
private readonly DispatcherTimer _timer;
private readonly EventHandler _tickHandler;
private int _remaining;
public ReminderPopupWindow(
string quoteText,
string? author,
TimeSpan todayUsage,
SettingsService settings)
{
InitializeComponent();
var cfg = settings.Settings.Reminder;
// ── 문구 / 출처 ──
QuoteText.Text = quoteText;
if (!string.IsNullOrWhiteSpace(author))
{
AuthorText.Text = $"— {author}";
AuthorText.Visibility = Visibility.Visible;
}
// ── 근무 시간 ──
var h = (int)todayUsage.TotalHours;
var m = todayUsage.Minutes;
UsageText.Text = h > 0
? $"오늘 총 {h}시간 {m}분 근무 중"
: m >= 1
? $"오늘 총 {m}분 근무 중"
: "오늘 방금 시작했습니다";
// ── 카운트다운 ──
_remaining = Math.Max(3, cfg.DisplaySeconds);
CountdownBar.Maximum = _remaining;
CountdownBar.Value = _remaining;
// ── 위치: 레이아웃 완료 후 설정 ──
Loaded += (_, _) =>
{
SetNoActivate();
PositionWindow(cfg.Corner);
AnimateIn();
};
// ── 타이머 ──
_tickHandler = (_, _) =>
{
_remaining--;
CountdownBar.Value = _remaining;
if (_remaining <= 0) Close();
};
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_timer.Tick += _tickHandler;
_timer.Start();
// ── Esc 키 닫기 ──
KeyDown += (_, e) =>
{
if (e.Key == System.Windows.Input.Key.Escape) Close();
};
}
// ─── 포커스 방지 ─────────────────────────────────────────────────────────
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
SetNoActivate();
}
private void SetNoActivate()
{
var hwnd = new WindowInteropHelper(this).Handle;
if (hwnd == IntPtr.Zero) return;
var style = (long)GetWindowLongPtr(hwnd, GWL_EXSTYLE);
SetWindowLongPtr(hwnd, GWL_EXSTYLE, (IntPtr)(style | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW));
}
// ─── 위치 계산 ────────────────────────────────────────────────────────────
private void PositionWindow(string corner)
{
var area = SystemParameters.WorkArea;
const double margin = 20;
Left = corner.Contains("left")
? area.Left + margin
: area.Right - ActualWidth - margin;
Top = corner.Contains("top")
? area.Top + margin
: area.Bottom - ActualHeight - margin;
}
// ─── 등장 애니메이션 ──────────────────────────────────────────────────────
private void AnimateIn()
{
Opacity = 0;
var anim = new System.Windows.Media.Animation.DoubleAnimation(0, 1,
TimeSpan.FromMilliseconds(280));
BeginAnimation(OpacityProperty, anim);
}
// ─── 이벤트 ───────────────────────────────────────────────────────────────
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
protected override void OnClosed(EventArgs e)
{
_timer.Stop();
_timer.Tick -= _tickHandler;
base.OnClosed(e);
}
}

View File

@@ -0,0 +1,292 @@
<Window x:Class="AxCopilot.Views.ResourceMonitorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Copilot — 리소스 모니터"
Width="420" Height="460"
MinWidth="360" MinHeight="400"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
ResizeMode="CanResize"
WindowStartupLocation="CenterScreen"
ShowInTaskbar="True"
Topmost="True"
Icon="pack://application:,,,/Assets/icon.ico"
MouseDown="Window_MouseDown"
KeyDown="Window_KeyDown">
<Border Margin="14">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="20" ShadowDepth="4" Opacity="0.24"/>
</Border.Effect>
<Border Background="{DynamicResource LauncherBackground}"
CornerRadius="16" ClipToBounds="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="*"/>
<RowDefinition Height="44"/>
</Grid.RowDefinitions>
<!-- ── 헤더 ── -->
<Border Grid.Row="0" CornerRadius="16,16,0,0">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1A1B2E" Offset="0"/>
<GradientStop Color="#2D3A6B" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid Margin="18,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets"
FontSize="15" Foreground="#88AAFFCC"
VerticalAlignment="Center" Margin="0,0,9,0"/>
<TextBlock Text="리소스 모니터" FontSize="14" FontWeight="SemiBold"
Foreground="White" VerticalAlignment="Center"/>
<!-- 실시간 갱신 표시 -->
<Border Background="#22FFFFFF" CornerRadius="4"
Padding="6,2" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock x:Name="RefreshLabel" Text="● LIVE" FontSize="9"
FontWeight="SemiBold" Foreground="#66FFAA"
VerticalAlignment="Center"/>
</Border>
</StackPanel>
<!-- 닫기 버튼 -->
<Button Width="26" Height="26" HorizontalAlignment="Right"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Click="Close_Click">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="6" Background="Transparent">
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets"
FontSize="9" Foreground="#88AAFFCC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#33FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Border>
<!-- ── 본문 ── -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="0,0,4,0">
<StackPanel Margin="20,16,20,8" >
<!-- CPU 게이지 -->
<Border Background="{DynamicResource ItemBackground}"
CornerRadius="12" Padding="16,14" Margin="0,0,0,10">
<StackPanel>
<Grid Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#4B9EFC"
VerticalAlignment="Center" Margin="0,0,7,0"/>
<TextBlock Text="CPU" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="CpuValueText"
Text="—" FontSize="20" FontWeight="Bold"
Foreground="#4B9EFC"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
<!-- CPU 바 -->
<Border Height="10" CornerRadius="5"
Background="{DynamicResource SeparatorColor}">
<Border x:Name="CpuBar" CornerRadius="5"
Background="#4B9EFC" HorizontalAlignment="Left"
Width="0"/>
</Border>
<TextBlock x:Name="CpuNameText"
Text="" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
Margin="0,6,0,0" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
<!-- RAM 게이지 -->
<Border Background="{DynamicResource ItemBackground}"
CornerRadius="12" Padding="16,14" Margin="0,0,0,10">
<StackPanel>
<Grid Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE950;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#7C3AED"
VerticalAlignment="Center" Margin="0,0,7,0"/>
<TextBlock Text="RAM" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Name="RamValueText"
Text="—" FontSize="20" FontWeight="Bold"
Foreground="#7C3AED"
HorizontalAlignment="Right" VerticalAlignment="Center"/>
</Grid>
<!-- RAM 바 -->
<Border Height="10" CornerRadius="5"
Background="{DynamicResource SeparatorColor}">
<Border x:Name="RamBar" CornerRadius="5"
Background="#7C3AED" HorizontalAlignment="Left"
Width="0"/>
</Border>
<TextBlock x:Name="RamDetailText"
Text="" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
Margin="0,6,0,0"/>
</StackPanel>
</Border>
<!-- 드라이브 섹션 -->
<TextBlock Text="드라이브" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}"
Margin="2,4,0,8"/>
<ItemsControl x:Name="DriveList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- DriveDisplayItem 바인딩 -->
<Border Background="{DynamicResource ItemBackground}"
CornerRadius="10" Padding="14,10" Margin="0,0,0,6"
Cursor="Hand">
<Border.InputBindings>
<MouseBinding MouseAction="LeftClick"
Command="{Binding OpenCommand}"/>
</Border.InputBindings>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="&#xEDA2;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#059669"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
TextTrimming="CharacterEllipsis"/>
<!-- 진행 바 -->
<Border Height="5" CornerRadius="3" Margin="0,5,0,0"
Background="{DynamicResource SeparatorColor}">
<Border CornerRadius="3" HorizontalAlignment="Left"
Background="{Binding BarColor}"
Width="{Binding BarWidth}"/>
</Border>
</StackPanel>
<TextBlock Grid.Column="2"
Text="{Binding Detail}"
FontSize="10" FontFamily="Consolas"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="10,0,0,0"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 상위 프로세스 섹션 -->
<TextBlock Text="CPU 상위 프로세스" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}"
Margin="2,8,0,8"/>
<ItemsControl x:Name="ProcessList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="22"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="52"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Rank}"
FontSize="10" Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Name}"
FontSize="11" FontFamily="Consolas"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="2" Text="{Binding MemText}"
FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Right"
VerticalAlignment="Center"/>
<Border Grid.Column="3" Height="6" CornerRadius="3"
Margin="6,0,0,0" VerticalAlignment="Center"
Background="{DynamicResource SeparatorColor}">
<Border CornerRadius="3" Background="#BE185D"
HorizontalAlignment="Left"
Width="{Binding BarWidth}"/>
</Border>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<!-- ── 리사이즈 그립 ── -->
<Border Grid.Row="2"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Width="20" Height="20" Margin="0,0,2,2"
Cursor="SizeNWSE" Background="Transparent"
Panel.ZIndex="100"
MouseLeftButtonDown="ResizeGrip_MouseLeftButtonDown">
<Canvas Width="13" Height="13">
<Rectangle Canvas.Left="10" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#70AAAACC"/>
<Rectangle Canvas.Left="6" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#50AAAACC"/>
<Rectangle Canvas.Left="10" Canvas.Top="6" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#50AAAACC"/>
<Rectangle Canvas.Left="2" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#30AAAACC"/>
<Rectangle Canvas.Left="6" Canvas.Top="6" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#30AAAACC"/>
<Rectangle Canvas.Left="10" Canvas.Top="2" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#30AAAACC"/>
</Canvas>
</Border>
<!-- ── 푸터 ── -->
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderColor}"
BorderThickness="0,1,0,0"
Background="{DynamicResource ItemBackground}"
CornerRadius="0,0,16,16">
<Grid Margin="18,0">
<TextBlock x:Name="UptimeText"
Text="가동 시간: —"
FontSize="10" Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<Button Content="닫기" HorizontalAlignment="Right"
Click="Close_Click" Width="68" Height="28"
Background="#4B5EFC" Foreground="White"
FontSize="11" FontWeight="SemiBold"
BorderThickness="0" Cursor="Hand">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#3B4EEC"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Border>
</Window>

View File

@@ -0,0 +1,328 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace AxCopilot.Views;
/// <summary>
/// CPU · RAM · 드라이브 · 상위 프로세스를 실시간으로 표시하는 플로팅 위젯.
/// info cpu 또는 info ram 항목의 Enter로 열립니다.
/// </summary>
public partial class ResourceMonitorWindow : Window
{
// ─── P/Invoke ───────────────────────────────────────────────────────────
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
[DllImport("user32.dll")] private static extern void ReleaseCapture();
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCLBUTTONDOWN = 0xA1;
private const int HTBOTTOMRIGHT = 17;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
// ─── 내부 모델 ──────────────────────────────────────────────────────────
public sealed class DriveDisplayItem : INotifyPropertyChanged
{
private string _label = "";
private string _detail = "";
private double _barWidth = 0;
private Brush _barColor = Brushes.Green;
public string Label { get => _label; set { _label = value; OnPropertyChanged(); } }
public string Detail { get => _detail; set { _detail = value; OnPropertyChanged(); } }
public double BarWidth { get => _barWidth; set { _barWidth = value; OnPropertyChanged(); } }
public Brush BarColor { get => _barColor; set { _barColor = value; OnPropertyChanged(); } }
/// <summary>탐색기로 드라이브 루트를 여는 커맨드</summary>
public ICommand OpenCommand { get; init; } = null!;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
public sealed class ProcessDisplayItem
{
public string Rank { get; init; } = "";
public string Name { get; init; } = "";
public string MemText { get; init; } = "";
public double BarWidth{ get; init; }
}
// ─── 필드 ───────────────────────────────────────────────────────────────
private readonly DispatcherTimer _timer;
private static PerformanceCounter? _cpuCounter;
private static float _cpuCached;
private static DateTime _cpuUpdated = DateTime.MinValue;
private readonly ObservableCollection<DriveDisplayItem> _drives = new();
// 드라이브 진행바 최대 너비 (px)
private const double MaxBarWidth = 160.0;
public ResourceMonitorWindow()
{
InitializeComponent();
DriveList.ItemsSource = _drives;
// 첫 CPU 카운터 초기화 (첫 샘플은 0이라 무의미)
if (_cpuCounter == null)
{
try
{
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
_cpuCounter.NextValue();
}
catch { /* PerformanceCounter 미지원 환경 */ }
}
// 드라이브 목록 초기 구성 (클릭 커맨드 포함)
InitDrives();
// 1초 주기 갱신
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_timer.Tick += (_, _) => Refresh();
_timer.Start();
Refresh(); // 즉시 첫 갱신
}
// ─── 드라이브 목록 초기화 ───────────────────────────────────────────────
private void InitDrives()
{
_drives.Clear();
foreach (var d in DriveInfo.GetDrives().Where(d => d.IsReady && d.DriveType == DriveType.Fixed))
{
var root = d.RootDirectory.FullName;
_drives.Add(new DriveDisplayItem
{
OpenCommand = new RelayCommand(() =>
{
try { Process.Start(new ProcessStartInfo("explorer.exe", root) { UseShellExecute = true }); }
catch { /* 무시 */ }
})
});
}
}
// ─── 갱신 ────────────────────────────────────────────────────────────────
private void Refresh()
{
RefreshCpu();
RefreshRam();
RefreshDrives();
RefreshProcesses();
RefreshUptime();
}
private void RefreshCpu()
{
try
{
if (_cpuCounter == null) { CpuValueText.Text = "—"; return; }
if ((DateTime.Now - _cpuUpdated).TotalMilliseconds > 800)
{
_cpuCached = _cpuCounter.NextValue();
_cpuUpdated = DateTime.Now;
}
var pct = (int)Math.Clamp(_cpuCached, 0, 100);
CpuValueText.Text = $"{pct}%";
// 색상: 낮음=파랑, 높음=빨강
var barBrush = pct > 80 ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
: pct > 50 ? new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06))
: new SolidColorBrush(Color.FromRgb(0x4B, 0x9E, 0xFC));
CpuValueText.Foreground = barBrush;
CpuBar.Background = barBrush;
CpuBar.Width = (CpuBar.Parent as FrameworkElement)?.ActualWidth * pct / 100.0 ?? 0;
// 프로세서 이름 (최초 1회)
if (string.IsNullOrEmpty(CpuNameText.Text))
CpuNameText.Text = GetProcessorName();
}
catch { CpuValueText.Text = "—"; }
}
private void RefreshRam()
{
var mem = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
if (!GlobalMemoryStatusEx(ref mem)) return;
var totalGb = mem.ullTotalPhys / 1024.0 / 1024 / 1024;
var usedGb = (mem.ullTotalPhys - mem.ullAvailPhys) / 1024.0 / 1024 / 1024;
var freeGb = mem.ullAvailPhys / 1024.0 / 1024 / 1024;
var pct = (int)mem.dwMemoryLoad;
RamValueText.Text = $"{pct}%";
RamDetailText.Text = $"사용: {usedGb:F1} GB / 전체: {totalGb:F1} GB / 여유: {freeGb:F1} GB";
RamBar.Width = (RamBar.Parent as FrameworkElement)?.ActualWidth * pct / 100.0 ?? 0;
var barBrush = pct > 85 ? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
: pct > 65 ? new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06))
: new SolidColorBrush(Color.FromRgb(0x7C, 0x3A, 0xED));
RamValueText.Foreground = barBrush;
RamBar.Background = barBrush;
}
private void RefreshDrives()
{
var drives = DriveInfo.GetDrives()
.Where(d => d.IsReady && d.DriveType == DriveType.Fixed)
.ToList();
// 드라이브 수가 바뀌면 재초기화
if (drives.Count != _drives.Count) InitDrives();
for (int i = 0; i < drives.Count && i < _drives.Count; i++)
{
var d = drives[i];
var totalGb = d.TotalSize / 1024.0 / 1024 / 1024;
var freeGb = d.AvailableFreeSpace / 1024.0 / 1024 / 1024;
var usedGb = totalGb - freeGb;
var pct = (int)(usedGb / totalGb * 100);
var lbl = string.IsNullOrWhiteSpace(d.VolumeLabel) ? d.Name.TrimEnd('\\') : $"{d.Name.TrimEnd('\\')} ({d.VolumeLabel})";
_drives[i].Label = lbl;
_drives[i].Detail = $"{freeGb:F1}GB 여유";
_drives[i].BarWidth = MaxBarWidth * pct / 100.0;
_drives[i].BarColor = pct > 90
? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
: pct > 75
? new SolidColorBrush(Color.FromRgb(0xD9, 0x77, 0x06))
: new SolidColorBrush(Color.FromRgb(0x05, 0x96, 0x69));
}
}
private void RefreshProcesses()
{
try
{
// 메모리 기준 상위 7개 (WorkingSet64 — 비동기 수집 없이도 빠름)
var procs = Process.GetProcesses()
.OrderByDescending(p => { try { return p.WorkingSet64; } catch { return 0L; } })
.Take(7)
.ToList();
long maxMem = procs.Count > 0 ? (long)(procs[0].WorkingSet64 > 0 ? procs[0].WorkingSet64 : 1) : 1;
var items = procs.Select((p, i) =>
{
long ws = 0;
string name = "";
try { ws = p.WorkingSet64; name = p.ProcessName; } catch { name = "—"; }
var mb = ws / 1024.0 / 1024;
return new ProcessDisplayItem
{
Rank = $"{i + 1}",
Name = name,
MemText = mb > 1024 ? $"{mb / 1024:F1} GB" : $"{mb:F0} MB",
BarWidth = Math.Max(2, 46.0 * ws / maxMem)
};
}).ToList();
ProcessList.ItemsSource = items;
}
catch { /* 권한 부족 등 무시 */ }
}
private void RefreshUptime()
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
var d = (int)uptime.TotalDays;
var h = uptime.Hours;
var m = uptime.Minutes;
UptimeText.Text = d > 0 ? $"가동: {d}일 {h}시간 {m}분"
: h > 0 ? $"가동: {h}시간 {m}분"
: $"가동: {m}분 {uptime.Seconds}초";
}
// ─── 이벤트 ─────────────────────────────────────────────────────────────
private void ResizeGrip_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.ButtonState != System.Windows.Input.MouseButtonState.Pressed) return;
ReleaseCapture();
SendMessage(new WindowInteropHelper(this).Handle, WM_NCLBUTTONDOWN, (IntPtr)HTBOTTOMRIGHT, IntPtr.Zero);
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Close();
}
private void Window_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.LeftButton == System.Windows.Input.MouseButtonState.Pressed)
try { DragMove(); } catch { }
}
/// <summary>Ctrl+마우스 휠로 창 높이 조절 (투명 창에서는 OS 리사이즈 그립이 없으므로)</summary>
protected override void OnMouseWheel(System.Windows.Input.MouseWheelEventArgs e)
{
if (Keyboard.Modifiers == ModifierKeys.Control)
{
var delta = e.Delta > 0 ? 40 : -40;
var newH = Height + delta;
if (newH >= MinHeight && newH <= 900)
Height = newH;
e.Handled = true;
return;
}
base.OnMouseWheel(e);
}
protected override void OnClosed(EventArgs e)
{
_timer.Stop();
base.OnClosed(e);
}
// ─── 헬퍼 ───────────────────────────────────────────────────────────────
private static string GetProcessorName()
{
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
return key?.GetValue("ProcessorNameString") as string ?? "";
}
catch { return ""; }
}
/// <summary>간단한 ICommand 구현 (RelayCommand)</summary>
private sealed class RelayCommand(Action execute) : ICommand
{
public event EventHandler? CanExecuteChanged { add { } remove { } }
public bool CanExecute(object? _) => true;
public void Execute(object? _) => execute();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
<Window x:Class="AxCopilot.Views.ShortcutHelpWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Copilot &#8212; &#xB2E8;&#xCD95;&#xD0A4; &#xBB34;&#xC5C7;&#xC774; &#xC788;&#xB098;&#xC694;"
Width="540" Height="620"
MinWidth="480" MinHeight="500"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
ResizeMode="CanResize"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
KeyDown="Window_KeyDown"
MouseDown="Window_MouseDown">
<Grid Margin="16">
<Border CornerRadius="16" Background="{DynamicResource LauncherBackground}">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="20" ShadowDepth="4" Opacity="0.22"/>
</Border.Effect>
</Border>
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="16" ClipToBounds="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="56"/>
<RowDefinition Height="*"/>
<RowDefinition Height="52"/>
</Grid.RowDefinitions>
<!-- ── 헤더 ── -->
<Border Grid.Row="0" CornerRadius="16,16,0,0">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1A1B2E" Offset="0"/>
<GradientStop Color="#3B4ECC" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid Margin="20,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE7C3;" FontFamily="Segoe MDL2 Assets"
FontSize="16" Foreground="#88AAFFCC"
VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="&#xB2E8;&#xCD95;&#xD0A4; &#xBB34;&#xC5C7;&#xC774; &#xC788;&#xB098;&#xC694;" FontSize="15" FontWeight="SemiBold"
Foreground="White" VerticalAlignment="Center"/>
</StackPanel>
<Button Width="28" Height="28" HorizontalAlignment="Right"
Background="Transparent" BorderThickness="0" Cursor="Hand"
Click="Close_Click">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="7" Background="Transparent">
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets"
FontSize="10" Foreground="#88AAFFCC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#22FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Border>
<!-- ── 내용 ── -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
Padding="0,0,4,0">
<StackPanel Margin="20,16,20,8">
<!-- 탐색 -->
<TextBlock Text="&#xD0D0;&#xC0C9;" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="2,0,0,6"/>
<ItemsControl x:Name="NavigationItems"/>
<!-- 파일 동작 -->
<TextBlock Text="&#xD30C;&#xC77C; &#xB3D9;&#xC791;" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="2,14,0,6"/>
<ItemsControl x:Name="FileActionItems"/>
<!-- 뷰 전환 -->
<TextBlock Text="&#xBDF0; &#xC804;&#xD658;" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="2,14,0,6"/>
<ItemsControl x:Name="ViewItems"/>
<!-- 실행 및 검색 -->
<TextBlock Text="&#xC2E4;&#xD589; &#xBC0F; &#xAE30;&#xD0C0;" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="2,14,0,6"/>
<ItemsControl x:Name="RunItems"/>
<!-- 예약어 -->
<TextBlock Text="&#xC785;&#xB825; &#xC608;&#xC57D;&#xC5B4;" FontSize="10" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="2,14,0,6"/>
<ItemsControl x:Name="PrefixItems"/>
</StackPanel>
</ScrollViewer>
<!-- ── 리사이즈 그립 ── -->
<Border Grid.Row="2"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Width="20" Height="20" Margin="0,0,2,2"
Cursor="SizeNWSE" Background="Transparent"
Panel.ZIndex="100"
MouseLeftButtonDown="ResizeGrip_MouseLeftButtonDown">
<Canvas Width="13" Height="13">
<Rectangle Canvas.Left="10" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#70AAAACC"/>
<Rectangle Canvas.Left="6" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#50AAAACC"/>
<Rectangle Canvas.Left="10" Canvas.Top="6" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#50AAAACC"/>
<Rectangle Canvas.Left="2" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#30AAAACC"/>
<Rectangle Canvas.Left="6" Canvas.Top="6" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#30AAAACC"/>
<Rectangle Canvas.Left="10" Canvas.Top="2" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#30AAAACC"/>
</Canvas>
</Border>
<!-- ── 푸터 ── -->
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Background="{DynamicResource ItemBackground}">
<Grid Margin="20,0">
<!-- 왼쪽: 테마 색상 토글 + Esc 안내 -->
<StackPanel Orientation="Vertical" HorizontalAlignment="Left" VerticalAlignment="Center">
<CheckBox x:Name="ThemeColorChk"
Content="테마 색상으로 표시"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
Checked="ThemeColorChk_Changed"
Unchecked="ThemeColorChk_Changed"
Margin="0,0,0,2"/>
<TextBlock Text="Esc 또는 클릭하면 닫힙니다"
FontSize="10" Foreground="{DynamicResource SecondaryText}"
Opacity="0.6"/>
</StackPanel>
<Button Content="&#xB2EB;&#xAE30;" HorizontalAlignment="Right"
Click="Close_Click" Width="72" Height="32"
Background="#4B5EFC" Foreground="White"
FontSize="12" FontWeight="SemiBold"
BorderThickness="0" Cursor="Hand">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="8"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#3B4EEC"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,238 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
namespace AxCopilot.Views;
/// <summary>Ctrl+K로 열리는 단축키 참조 모달 창</summary>
public partial class ShortcutHelpWindow : Window
{
// ─── P/Invoke ────────────────────────────────────────────────────────────
[DllImport("user32.dll")] private static extern void ReleaseCapture();
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCLBUTTONDOWN = 0xA1;
private const int HTBOTTOMRIGHT = 17;
// (키 문자열, 설명, 아이콘 코드, 배경색)
private record ShortcutRow(string Key, string Description, string Icon = "\uE72D", string Color = "#4B5EFC");
public ShortcutHelpWindow()
{
InitializeComponent();
Loaded += (_, _) =>
{
// 저장된 설정 로드
var svc = (System.Windows.Application.Current as App)?.SettingsService;
if (svc != null)
ThemeColorChk.IsChecked = svc.Settings.Launcher.ShortcutHelpUseThemeColor;
BuildItems();
};
}
private void BuildItems()
{
bool useTheme = ThemeColorChk.IsChecked == true;
// ── 탐색 ──────────────────────────────────────────────────────────
var nav = new[]
{
new ShortcutRow("↑ / ↓", "결과 목록 위/아래로 이동", "\uE74A", "#4B5EFC"),
new ShortcutRow("PageUp / PageDown","목록 5칸 빠른 이동", "\uE74A", "#4B5EFC"),
new ShortcutRow("Home / End", "목록 처음 항목 / 마지막 항목으로 점프", "\uE74A", "#4B5EFC"),
new ShortcutRow("Tab", "선택 항목 제목을 입력창에 자동 완성", "\uE748", "#7B68EE"),
new ShortcutRow("→", "파일/앱 선택 시 액션 모드(복사·실행 등) 진입", "\uE76C", "#107C10"),
new ShortcutRow("Escape", "액션 모드면 일반 모드로 복귀, 아니면 AX Commander 닫기", "\uE711", "#C50F1F"),
};
// ── 파일 동작 ──────────────────────────────────────────────────────
var file = new[]
{
new ShortcutRow("Enter", "선택 항목 실행 (파일 열기·명령 실행·URL 열기 등)", "\uE768", "#107C10"),
new ShortcutRow("Ctrl+Enter", "선택 파일·앱을 관리자(UAC 상승) 권한으로 실행", "\uE7EF", "#C50F1F"),
new ShortcutRow("Alt+Enter", "선택 항목의 Windows 속성 대화 상자 열기", "\uE7EE", "#8B2FC9"),
new ShortcutRow("Ctrl+C", "선택 항목의 파일 이름(확장자 제외)을 클립보드에 복사","\uE8C8", "#0078D4"),
new ShortcutRow("Ctrl+Shift+C", "선택 항목의 전체 경로를 클립보드에 복사", "\uE8C8", "#0078D4"),
new ShortcutRow("Ctrl+Shift+E", "파일 탐색기를 열고 선택 항목 위치를 하이라이트", "\uE8DA", "#107C10"),
new ShortcutRow("Ctrl+T", "선택 항목 폴더 경로에서 터미널(wt/cmd) 열기", "\uE756", "#323130"),
new ShortcutRow("Ctrl+P", "선택 항목을 즐겨찾기에 추가 (이미 있으면 제거)", "\uE734", "#D97706"),
new ShortcutRow("F2", "선택 파일·폴더의 이름 변경 모드로 전환", "\uE70F", "#6B2C91"),
new ShortcutRow("Delete", "선택 항목을 최근 목록에서 제거 (확인 후 실행)", "\uE74D", "#C50F1F"),
};
// ── 뷰 전환 ────────────────────────────────────────────────────────
var view = new[]
{
new ShortcutRow("Ctrl+B", "즐겨찾기 목록 보기 (다시 누르면 닫기, 토글)", "\uE735", "#D97706"),
new ShortcutRow("Ctrl+R", "최근 실행 목록 보기 (다시 누르면 닫기, 토글)", "\uE81C", "#0078D4"),
new ShortcutRow("Ctrl+H", "클립보드 히스토리 목록 보기", "\uE77F", "#8B2FC9"),
new ShortcutRow("Ctrl+D", "다운로드 폴더 경로 바로 열기", "\uE8B7", "#107C10"),
new ShortcutRow("Ctrl+F", "입력 초기화 후 파일 검색 모드로 포커스 이동", "\uE71E", "#4B5EFC"),
new ShortcutRow("F1", "헬프 화면 열기 (help 입력과 동일)", "\uE897", "#4B5EFC"),
};
// ── 실행 및 기타 ───────────────────────────────────────────────────
var run = new[]
{
new ShortcutRow("Ctrl+1 ~ Ctrl+9", "표시 중인 N번째 결과를 즉시 실행 (번호 배지와 연동)", "\uE8C4", "#107C10"),
new ShortcutRow("Ctrl+L", "입력창 전체 초기화", "\uE711", "#9999BB"),
new ShortcutRow("Ctrl+W", "AX Commander 창 즉시 닫기", "\uE711", "#9999BB"),
new ShortcutRow("Ctrl+K", "이 단축키 참조 창 열기", "\uE8FD", "#4B5EFC"),
new ShortcutRow("Ctrl+,", "설정 창 열기", "\uE713", "#4B5EFC"),
new ShortcutRow("F5", "파일 인덱스 즉시 재구축", "\uE72C", "#107C10"),
new ShortcutRow("Shift+Enter", "Large Type / 병합 실행 / 캡처 모드: 지연 캡처(3/5/10초) 타이머", "\uE8A7", "#7B68EE"),
};
// ── 입력 예약어 ───────────────────────────────────────────────────
var prefix = new[]
{
new ShortcutRow("cd [경로/별칭]", "지정한 경로 또는 등록된 폴더 별칭을 탐색기로 열기", "\uE8DA", "#107C10"),
new ShortcutRow("~ [이름]", "워크스페이스 저장(save)/복원(restore)/목록(list)", "\uE8B7", "#C50F1F"),
new ShortcutRow("# [검색어]", "클립보드 히스토리 검색·붙여넣기", "\uE77F", "#8B2FC9"),
new ShortcutRow("fav [검색어]", "즐겨찾기 목록 검색 및 열기. add/del 로 관리", "\uE735", "#D97706"),
new ShortcutRow("recent [검색어]", "최근 실행 항목 검색", "\uE81C", "#0078D4"),
new ShortcutRow("cap [모드]", "화면 캡처 (region/window/scroll/screen) · Shift+Enter: 지연 캡처", "\uE722", "#C09010"),
new ShortcutRow("help [검색어]", "도움말 및 기능 목록 검색", "\uE897", "#4B5EFC"),
new ShortcutRow("/ [명령]", "시스템 명령 (lock/sleep/restart/shutdown 등)", "\uE7E8", "#C50F1F"),
new ShortcutRow("! [질문]", "AX Agent — Ollama/vLLM/Gemini/Claude", "\uE8BD", "#8B2FC9"),
};
NavigationItems.Items.Clear();
FileActionItems.Items.Clear();
ViewItems.Items.Clear();
RunItems.Items.Clear();
PrefixItems.Items.Clear();
Populate(NavigationItems, nav, useTheme);
Populate(FileActionItems, file, useTheme);
Populate(ViewItems, view, useTheme);
Populate(RunItems, run, useTheme);
Populate(PrefixItems, prefix, useTheme);
}
private static void Populate(ItemsControl control, ShortcutRow[] rows, bool useThemeColor)
{
// 현재 테마에서 텍스트/배경 색상 읽기
var primaryText = System.Windows.Application.Current.TryFindResource("PrimaryText") as Brush
?? new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x55));
var itemBg = System.Windows.Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0xEE, 0xEE, 0xFF));
var accentBrush = System.Windows.Application.Current.TryFindResource("AccentColor") as SolidColorBrush;
var accentHex = accentBrush != null ? $"#{accentBrush.Color.R:X2}{accentBrush.Color.G:X2}{accentBrush.Color.B:X2}" : "#4B5EFC";
foreach (var row in rows)
{
var colorHex = useThemeColor ? accentHex : row.Color;
var grid = new Grid { Margin = new Thickness(0, 0, 0, 3) };
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(148) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
// 아이콘 배지
var iconBorder = new Border
{
Width = 22, Height = 22,
CornerRadius = new CornerRadius(5),
Background = ParseBrush(colorHex + "22"),
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Center,
};
iconBorder.Child = new TextBlock
{
Text = row.Icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = ParseBrush(colorHex),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(iconBorder, 0);
grid.Children.Add(iconBorder);
// 키 배지 — 테마 ItemBackground 사용
var keyBorder = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(5),
Padding = new Thickness(7, 3, 7, 3),
Margin = new Thickness(4, 1, 8, 1),
VerticalAlignment = VerticalAlignment.Center,
HorizontalAlignment = HorizontalAlignment.Left,
};
keyBorder.Child = new TextBlock
{
Text = row.Key,
FontFamily = new FontFamily("Consolas, Courier New"),
FontSize = 11,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(keyBorder, 1);
grid.Children.Add(keyBorder);
// 설명 — 테마 PrimaryText 사용
var desc = new TextBlock
{
Text = row.Description,
FontSize = 11.5,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap,
LineHeight = 16,
};
Grid.SetColumn(desc, 2);
grid.Children.Add(desc);
control.Items.Add(grid);
}
}
private static SolidColorBrush ParseBrush(string hex)
{
try { return new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); }
catch { return new SolidColorBrush(Colors.Transparent); }
}
// ─── 테마 색상 체크박스 ─────────────────────────────────────────────────
private void ThemeColorChk_Changed(object sender, RoutedEventArgs e)
{
// 설정 저장
var svc = (System.Windows.Application.Current as App)?.SettingsService;
if (svc != null)
{
svc.Settings.Launcher.ShortcutHelpUseThemeColor = ThemeColorChk.IsChecked == true;
svc.Save();
}
// 목록 다시 그리기
BuildItems();
}
// ─── 리사이즈 그립 ──────────────────────────────────────────────────────
private void ResizeGrip_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState != MouseButtonState.Pressed) return;
ReleaseCapture();
SendMessage(new WindowInteropHelper(this).Handle, WM_NCLBUTTONDOWN, (IntPtr)HTBOTTOMRIGHT, IntPtr.Zero);
}
// ─── 이벤트 ─────────────────────────────────────────────────────────────
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private void Window_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Close();
}
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed)
try { DragMove(); } catch { }
}
}

View File

@@ -0,0 +1,258 @@
<Window x:Class="AxCopilot.Views.SkillEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="스킬 편집기"
Width="780" Height="660"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.3" Color="Black"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ── 타이틀바 ── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0" Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="16,0,0,0">
<TextBlock Text="&#xE70F;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#4B5EFC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock x:Name="TitleText" Text="새 스킬 만들기" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,12,0">
<Border Width="28" Height="28" CornerRadius="6" Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#33FF4444"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- ── 메인 콘텐츠 ── -->
<Grid Grid.Row="1" Margin="16,10,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="260"/>
</Grid.ColumnDefinitions>
<!-- ═══ 좌측: 편집 폼 ═══ -->
<ScrollViewer Grid.Column="0" VerticalScrollBarVisibility="Auto" Margin="0,0,12,0">
<StackPanel>
<!-- 이름 + 레이블 -->
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,6,0">
<TextBlock Text="이름 (영문, 슬래시 명령)" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="TxtName" FontSize="13" Padding="10,8,10,8"
Background="{DynamicResource ItemBackground}"
Foreground="{DynamicResource PrimaryText}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
MaxLength="40">
<TextBox.Resources>
<Style TargetType="Border"><Setter Property="CornerRadius" Value="8"/></Style>
</TextBox.Resources>
</TextBox>
</StackPanel>
<StackPanel Grid.Column="1" Margin="6,0,0,0">
<TextBlock Text="표시 이름 (한글)" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="TxtLabel" FontSize="13" Padding="10,8,10,8"
Background="{DynamicResource ItemBackground}"
Foreground="{DynamicResource PrimaryText}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
MaxLength="60">
<TextBox.Resources>
<Style TargetType="Border"><Setter Property="CornerRadius" Value="8"/></Style>
</TextBox.Resources>
</TextBox>
</StackPanel>
</Grid>
<!-- 설명 -->
<StackPanel Margin="0,0,0,10">
<TextBlock Text="설명" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="TxtDescription" FontSize="13" Padding="10,8,10,8"
Background="{DynamicResource ItemBackground}"
Foreground="{DynamicResource PrimaryText}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
MaxLength="200">
<TextBox.Resources>
<Style TargetType="Border"><Setter Property="CornerRadius" Value="8"/></Style>
</TextBox.Resources>
</TextBox>
</StackPanel>
<!-- 아이콘 + 런타임 요구사항 -->
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,12,0">
<TextBlock Text="아이콘" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<StackPanel x:Name="IconSelectorPanel" Orientation="Horizontal"/>
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="런타임 요구사항" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<StackPanel Orientation="Horizontal">
<ComboBox x:Name="CmbRequires" Width="160" VerticalAlignment="Center"
SelectedIndex="0">
<ComboBoxItem Content="없음 (기본)" Tag=""/>
<ComboBoxItem Content="Python" Tag="python"/>
<ComboBoxItem Content="Node.js" Tag="node"/>
<ComboBoxItem Content="Python + Node.js" Tag="python,node"/>
</ComboBox>
</StackPanel>
</StackPanel>
</Grid>
<!-- 지시사항 (마크다운 편집) -->
<StackPanel Margin="0,0,0,6">
<Grid Margin="0,0,0,4">
<TextBlock Text="지시사항 (마크다운)" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Border x:Name="BtnInsertToolList" CornerRadius="4" Padding="8,2,8,2"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnInsertTemplate_Click" Tag="tools"
Margin="0,0,4,0">
<TextBlock Text="도구 목록 삽입" FontSize="10"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border x:Name="BtnInsertFormat" CornerRadius="4" Padding="8,2,8,2"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnInsertTemplate_Click" Tag="format"
Margin="0,0,4,0">
<TextBlock Text="출력 형식 삽입" FontSize="10"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border CornerRadius="4" Padding="8,2,8,2"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnInsertTemplate_Click" Tag="steps">
<TextBlock Text="단계별 삽입" FontSize="10"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</StackPanel>
</Grid>
<TextBox x:Name="TxtInstructions" FontSize="12.5" Padding="10,8,10,8"
AcceptsReturn="True" AcceptsTab="True"
VerticalScrollBarVisibility="Auto"
Height="220"
FontFamily="Consolas, Cascadia Code, Segoe UI"
Background="{DynamicResource ItemBackground}"
Foreground="{DynamicResource PrimaryText}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
TextWrapping="Wrap"
TextChanged="TxtInstructions_TextChanged">
<TextBox.Resources>
<Style TargetType="Border"><Setter Property="CornerRadius" Value="8"/></Style>
</TextBox.Resources>
</TextBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ═══ 우측: 도구 선택 + 미리보기 ═══ -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 도구 체크리스트 -->
<Border Grid.Row="0" Background="{DynamicResource ItemBackground}"
CornerRadius="10" Padding="12,10,12,10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="사용할 도구 (allowed-tools)"
FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,8"/>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="ToolCheckListPanel"/>
</ScrollViewer>
</Grid>
</Border>
<!-- 미리보기 정보 -->
<Border Grid.Row="1" Background="{DynamicResource ItemBackground}"
CornerRadius="10" Padding="12,10,12,10" Margin="0,8,0,0">
<StackPanel>
<TextBlock Text="미리보기" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,6"/>
<TextBlock x:Name="PreviewTokens" FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
<TextBlock x:Name="PreviewFileName" FontSize="10.5" FontFamily="Consolas"
Foreground="{DynamicResource SecondaryText}" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</Grid>
</Grid>
<!-- ── 하단 버튼 ── -->
<Border Grid.Row="2" BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Padding="16,10,16,10" CornerRadius="0,0,12,12">
<Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock x:Name="StatusText" FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Border CornerRadius="8" Padding="18,8,18,8" Margin="0,0,8,0"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnCancel_Click">
<TextBlock Text="취소" FontSize="12.5"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border CornerRadius="8" Padding="18,8,18,8" Margin="0,0,8,0"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnPreview_Click">
<TextBlock Text="미리보기" FontSize="12.5"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border CornerRadius="8" Padding="22,8,22,8"
Background="#4B5EFC" Cursor="Hand"
MouseLeftButtonUp="BtnSave_Click">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74E;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="White" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="저장" FontSize="12.5" FontWeight="SemiBold" Foreground="White"/>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,528 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>스킬 시각적 편집기. 새 스킬 생성 또는 기존 스킬 편집을 GUI로 지원합니다.</summary>
public partial class SkillEditorWindow : Window
{
// ── 아이콘 후보 목록 (Segoe MDL2 Assets 코드포인트) ──
private static readonly string[] IconCandidates =
[
"\uE70F", // Edit
"\uE8A5", // Document
"\uE943", // Code
"\uE74C", // Search
"\uE8B7", // Folder
"\uE896", // People
"\uE713", // Settings
"\uE753", // Cloud
"\uE774", // Camera
"\uE8D6", // Mail
"\uE8F1", // Lightbulb
"\uE7C3", // Data
"\uECA7", // Rocket
"\uE71E", // Chart
"\uE8C8", // Copy
"\uE8F6", // Process
"\uE81E", // Link
"\uEBD2", // AI
"\uE9D9", // Globe
"\uE77B", // Shield
];
private string _selectedIcon = "\uE70F";
private SkillDefinition? _editingSkill;
private readonly ToolRegistry _toolRegistry;
/// <summary>새 스킬 모드로 열기.</summary>
public SkillEditorWindow()
{
InitializeComponent();
_toolRegistry = ToolRegistry.CreateDefault();
Loaded += (_, _) => { BuildIconSelector(); BuildToolChecklist(); UpdatePreview(); };
}
/// <summary>편집 모드로 열기.</summary>
public SkillEditorWindow(SkillDefinition skill) : this()
{
_editingSkill = skill;
Loaded += (_, _) => LoadSkill(skill);
}
// ─── 타이틀바 ─────────────────────────────────────────────────────────
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// 타이틀바 우측 닫기 버튼 영역에서는 DragMove 실행하지 않음
var pos = e.GetPosition(this);
if (pos.X > ActualWidth - 50) return;
if (e.ClickCount == 2)
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
else DragMove();
}
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close();
// ─── 아이콘 선택기 ────────────────────────────────────────────────────
private void BuildIconSelector()
{
IconSelectorPanel.Children.Clear();
var accentBrush = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var bgBrush = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
foreach (var icon in IconCandidates)
{
var isSelected = icon == _selectedIcon;
var border = new Border
{
Width = 34,
Height = 34,
CornerRadius = new CornerRadius(8),
Margin = new Thickness(0, 0, 4, 4),
Cursor = Cursors.Hand,
Background = isSelected ? accentBrush : bgBrush,
BorderBrush = isSelected ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(1.5),
Tag = icon,
};
border.Child = new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
Foreground = isSelected ? Brushes.White : subBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
border.MouseLeftButtonUp += (s, _) =>
{
if (s is Border b && b.Tag is string ic)
{
_selectedIcon = ic;
BuildIconSelector();
}
};
// 호버
var capturedIcon = icon;
border.MouseEnter += (s, _) =>
{
if (s is Border b && capturedIcon != _selectedIcon)
b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC));
};
border.MouseLeave += (s, _) =>
{
if (s is Border b && capturedIcon != _selectedIcon)
b.Background = bgBrush;
};
IconSelectorPanel.Children.Add(border);
}
}
// ─── 도구 체크리스트 ──────────────────────────────────────────────────
private void BuildToolChecklist()
{
ToolCheckListPanel.Children.Clear();
var tools = _toolRegistry.All.OrderBy(t => t.Name).ToList();
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
// 기존 스킬 편집 시 활성 도구 파싱
var activeTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (_editingSkill != null && !string.IsNullOrWhiteSpace(_editingSkill.AllowedTools))
{
foreach (var t in _editingSkill.AllowedTools.Split(',', StringSplitOptions.RemoveEmptyEntries))
activeTools.Add(t.Trim());
}
foreach (var tool in tools)
{
var cb = new CheckBox
{
Tag = tool.Name,
IsChecked = activeTools.Count == 0 || activeTools.Contains(tool.Name),
Margin = new Thickness(0, 0, 0, 4),
Style = TryFindResource("ToggleSwitch") as Style,
};
var row = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
};
row.Children.Add(cb);
row.Children.Add(new TextBlock
{
Text = tool.Name,
FontSize = 11.5,
FontFamily = new FontFamily("Consolas"),
Foreground = fgBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(6, 0, 0, 0),
});
// 설명 툴팁
if (!string.IsNullOrEmpty(tool.Description))
row.ToolTip = tool.Description;
ToolCheckListPanel.Children.Add(row);
}
}
/// <summary>체크된 도구 이름 목록을 가져옵니다.</summary>
private List<string> GetCheckedTools()
{
var result = new List<string>();
foreach (var child in ToolCheckListPanel.Children)
{
if (child is StackPanel row && row.Children.Count > 0 && row.Children[0] is CheckBox cb)
{
if (cb.IsChecked == true && cb.Tag is string name)
result.Add(name);
}
}
return result;
}
// ─── 템플릿 삽입 ──────────────────────────────────────────────────────
private void BtnInsertTemplate_Click(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement el || el.Tag is not string tag) return;
var template = tag switch
{
"tools" => BuildToolListTemplate(),
"format" => """
##
1. .
2. .
3. , .
""",
"steps" => """
##
1. ****: .
2. ****: .
3. ****: .
4. ****: .
5. ****: .
""",
_ => "",
};
if (string.IsNullOrEmpty(template)) return;
var insertPos = TxtInstructions.CaretIndex;
var prefix = insertPos > 0 && TxtInstructions.Text.Length > 0
&& TxtInstructions.Text[insertPos - 1] != '\n' ? "\n\n" : "";
TxtInstructions.Text = TxtInstructions.Text.Insert(insertPos, prefix + template.Trim());
TxtInstructions.CaretIndex = insertPos + prefix.Length + template.Trim().Length;
TxtInstructions.Focus();
}
private string BuildToolListTemplate()
{
var checkedTools = GetCheckedTools();
if (checkedTools.Count == 0)
return "## 사용 가능한 도구\n\n(도구가 선택되지 않았습니다. 우측 패널에서 도구를 선택하세요.)";
var lines = new List<string> { "## 사용 가능한 도구", "" };
foreach (var toolName in checkedTools)
{
var tool = _toolRegistry.All.FirstOrDefault(t => t.Name == toolName);
if (tool != null)
lines.Add($"- **{tool.Name}**: {tool.Description}");
}
return string.Join("\n", lines);
}
// ─── 미리보기 업데이트 ────────────────────────────────────────────────
private void TxtInstructions_TextChanged(object sender, TextChangedEventArgs e) => UpdatePreview();
private void UpdatePreview()
{
if (PreviewTokens == null || PreviewFileName == null) return;
var content = GenerateSkillContent();
var tokens = TokenEstimator.Estimate(content);
PreviewTokens.Text = $"예상 토큰: ~{tokens:N0}자 | {content.Length:N0}자";
var name = string.IsNullOrWhiteSpace(TxtName?.Text) ? "new-skill" : TxtName.Text.Trim();
PreviewFileName.Text = $"{name}.skill.md";
}
// ─── 스킬 콘텐츠 생성 ────────────────────────────────────────────────
private string GenerateSkillContent()
{
var name = TxtName?.Text?.Trim() ?? "new-skill";
var label = TxtLabel?.Text?.Trim() ?? "";
var desc = TxtDescription?.Text?.Trim() ?? "";
var instructions = TxtInstructions?.Text ?? "";
// 런타임 요구사항
var requiresTag = "";
if (CmbRequires?.SelectedItem is ComboBoxItem cbi && cbi.Tag is string req && !string.IsNullOrEmpty(req))
requiresTag = req;
// 도구 목록
var checkedTools = GetCheckedTools();
var allToolCount = _toolRegistry.All.Count;
var allowedToolsStr = checkedTools.Count < allToolCount
? string.Join(", ", checkedTools)
: ""; // 전체 선택이면 비워둠
// YAML 프론트매터 생성
var yaml = new List<string> { "---" };
yaml.Add($"name: {name}");
if (!string.IsNullOrEmpty(label)) yaml.Add($"label: {label}");
if (!string.IsNullOrEmpty(desc)) yaml.Add($"description: {desc}");
yaml.Add($"icon: {_selectedIcon}");
if (!string.IsNullOrEmpty(requiresTag)) yaml.Add($"requires: {requiresTag}");
if (!string.IsNullOrEmpty(allowedToolsStr)) yaml.Add($"allowed-tools: {allowedToolsStr}");
yaml.Add("---");
yaml.Add("");
return string.Join("\n", yaml) + instructions;
}
// ─── 미리보기 버튼 ───────────────────────────────────────────────────
private void BtnPreview_Click(object sender, MouseButtonEventArgs e)
{
var content = GenerateSkillContent();
var previewWin = new Window
{
Title = "스킬 파일 미리보기",
Width = 640,
Height = 520,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
};
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var outerBorder = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(12),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black,
},
};
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) });
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
// 타이틀바
var titleBar = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(12, 12, 0, 0),
};
titleBar.MouseLeftButtonDown += (_, _) => previewWin.DragMove();
var titleText = new TextBlock
{
Text = "미리보기 — .skill.md",
FontSize = 14,
FontWeight = FontWeights.SemiBold,
Foreground = fgBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(16, 0, 0, 0),
};
titleBar.Child = titleText;
Grid.SetRow(titleBar, 0);
// 콘텐츠
var textBox = new TextBox
{
Text = content,
FontFamily = new FontFamily("Consolas, Cascadia Code, Segoe UI"),
FontSize = 12.5,
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Background = itemBg,
Foreground = fgBrush,
BorderThickness = new Thickness(0),
Padding = new Thickness(16, 12, 16, 12),
Margin = new Thickness(8, 8, 8, 0),
};
Grid.SetRow(textBox, 1);
// 하단
var bottomBar = new Border
{
Padding = new Thickness(16, 10, 16, 10),
CornerRadius = new CornerRadius(0, 0, 12, 12),
};
var closeBtn = new Border
{
CornerRadius = new CornerRadius(8),
Padding = new Thickness(18, 8, 18, 8),
Background = itemBg,
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
};
closeBtn.Child = new TextBlock
{
Text = "닫기",
FontSize = 12.5,
Foreground = subBrush,
};
closeBtn.MouseLeftButtonUp += (_, _) => previewWin.Close();
closeBtn.MouseEnter += (s, _) =>
{
if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
};
closeBtn.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = itemBg;
};
bottomBar.Child = closeBtn;
Grid.SetRow(bottomBar, 2);
grid.Children.Add(titleBar);
grid.Children.Add(textBox);
grid.Children.Add(bottomBar);
outerBorder.Child = grid;
previewWin.Content = outerBorder;
previewWin.ShowDialog();
}
// ─── 저장 ─────────────────────────────────────────────────────────────
private void BtnSave_Click(object sender, MouseButtonEventArgs e)
{
// 유효성 검사
var name = TxtName.Text.Trim();
if (string.IsNullOrWhiteSpace(name))
{
StatusText.Text = "⚠ 이름을 입력하세요.";
StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71));
TxtName.Focus();
return;
}
// 영문 + 하이픈 + 숫자만 허용
if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-zA-Z][a-zA-Z0-9\-]*$"))
{
StatusText.Text = "⚠ 이름은 영문으로 시작하며 영문, 숫자, 하이픈만 사용 가능합니다.";
StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71));
TxtName.Focus();
return;
}
if (string.IsNullOrWhiteSpace(TxtInstructions.Text))
{
StatusText.Text = "⚠ 지시사항을 입력하세요.";
StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71));
TxtInstructions.Focus();
return;
}
var content = GenerateSkillContent();
// 저장 경로 결정
string savePath;
if (_editingSkill != null)
{
// 편집 모드: 기존 파일 덮어쓰기
savePath = _editingSkill.FilePath;
}
else
{
// 새 스킬: 사용자 폴더에 저장
var userFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
if (!Directory.Exists(userFolder))
Directory.CreateDirectory(userFolder);
savePath = Path.Combine(userFolder, $"{name}.skill.md");
// 파일 이름 충돌 시 숫자 추가
if (File.Exists(savePath))
{
var counter = 2;
while (File.Exists(Path.Combine(userFolder, $"{name}_{counter}.skill.md")))
counter++;
savePath = Path.Combine(userFolder, $"{name}_{counter}.skill.md");
}
}
try
{
File.WriteAllText(savePath, content, System.Text.Encoding.UTF8);
SkillService.LoadSkills();
StatusText.Text = $"✓ 저장 완료: {Path.GetFileName(savePath)}";
StatusText.Foreground = new SolidColorBrush(Color.FromRgb(0x34, 0xD3, 0x99));
// 편집 결과 반환
DialogResult = true;
}
catch (Exception ex)
{
CustomMessageBox.Show($"저장 실패: {ex.Message}", "스킬 저장");
}
}
// ─── 편집 모드 로드 ───────────────────────────────────────────────────
private void LoadSkill(SkillDefinition skill)
{
TitleText.Text = "스킬 편집";
TxtName.Text = skill.Name;
TxtLabel.Text = skill.Label;
TxtDescription.Text = skill.Description;
TxtInstructions.Text = skill.SystemPrompt;
// 아이콘 선택
_selectedIcon = IconCandidates.Contains(skill.Icon) ? skill.Icon : IconCandidates[0];
BuildIconSelector();
// 런타임 요구사항
for (int i = 0; i < CmbRequires.Items.Count; i++)
{
if (CmbRequires.Items[i] is ComboBoxItem item
&& item.Tag is string tag
&& string.Equals(tag, skill.Requires, StringComparison.OrdinalIgnoreCase))
{
CmbRequires.SelectedIndex = i;
break;
}
}
// 도구 체크리스트 (BuildToolChecklist에서 이미 _editingSkill 기반으로 설정됨)
UpdatePreview();
}
}

View File

@@ -0,0 +1,100 @@
<Window x:Class="AxCopilot.Views.SkillGalleryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="스킬 갤러리"
Width="740" Height="560"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.3" Color="Black"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ── 타이틀바 ── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0" Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<Grid Margin="16,0,12,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 왼쪽: 제목 -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE768;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#4B5EFC" VerticalAlignment="Center" Margin="0,1,8,0"/>
<TextBlock Text="스킬 갤러리" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
<!-- 오른쪽: 버튼들 -->
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Border CornerRadius="6" Padding="10,5" Margin="0,0,6,0"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnAddSkill_Click">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE710;" FontFamily="Segoe MDL2 Assets" FontSize="11"
Foreground="#34D399" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="스킬 추가" FontSize="12" Foreground="#34D399"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border CornerRadius="6" Padding="10,5" Margin="0,0,6,0"
Background="{DynamicResource ItemBackground}" Cursor="Hand"
MouseLeftButtonUp="BtnImport_Click">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE8B5;" FontFamily="Segoe MDL2 Assets" FontSize="11"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="가져오기" FontSize="12" Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="BtnCloseGallery" Width="28" Height="28" CornerRadius="6" Cursor="Hand"
VerticalAlignment="Center"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#33FF4444"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
</Border>
<!-- ── 카테고리 필터 ── -->
<Border Grid.Row="1" Padding="16,10,16,0">
<StackPanel x:Name="CategoryFilterBar" Orientation="Horizontal"/>
</Border>
<!-- ── 스킬 목록 ── -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" Padding="16,10,16,0">
<StackPanel x:Name="SkillListPanel"/>
</ScrollViewer>
<!-- ── 상태바 ── -->
<Border Grid.Row="3" BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Padding="16,8" CornerRadius="0,0,12,12">
<TextBlock x:Name="GalleryStatus" FontSize="11"
Foreground="{DynamicResource SecondaryText}"
Text="스킬을 불러오는 중..."/>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,635 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>스킬 갤러리 창. 설치된 모든 스킬을 카드 형태로 표시하고 관리합니다.</summary>
public partial class SkillGalleryWindow : Window
{
private string _selectedCategory = "전체";
public SkillGalleryWindow()
{
InitializeComponent();
Loaded += (_, _) => { BuildCategoryFilter(); RenderSkills(); };
}
// ─── 타이틀바 ─────────────────────────────────────────────────────────
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// 타이틀바 우측 버튼 영역에서는 DragMove 실행하지 않음
var pos = e.GetPosition(this);
if (pos.X > ActualWidth - 160) return; // 우측 버튼 영역 보호
if (e.ClickCount == 2)
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
else DragMove();
}
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
// ─── 버튼 ─────────────────────────────────────────────────────────────
private void BtnAddSkill_Click(object sender, MouseButtonEventArgs e)
{
var editor = new SkillEditorWindow { Owner = this };
if (editor.ShowDialog() == true)
{
BuildCategoryFilter();
RenderSkills();
}
}
private void BtnImport_Click(object sender, MouseButtonEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Filter = "스킬 패키지 (*.zip)|*.zip",
Title = "가져올 스킬 zip 파일을 선택하세요",
};
if (dlg.ShowDialog() != true) return;
var count = SkillService.ImportSkills(dlg.FileName);
if (count > 0)
{
CustomMessageBox.Show($"스킬 {count}개를 성공적으로 가져왔습니다.", "스킬 가져오기");
BuildCategoryFilter();
RenderSkills();
}
else
CustomMessageBox.Show("스킬 가져오기에 실패했습니다.", "스킬 가져오기");
}
// ─── 카테고리 필터 ─────────────────────────────────────────────────────
private void BuildCategoryFilter()
{
CategoryFilterBar.Children.Clear();
var skills = SkillService.Skills;
var categories = new[] { "전체" }
.Concat(skills
.Select(s => string.IsNullOrEmpty(s.Requires) ? "내장" : "고급 (런타임)")
.Distinct())
.ToList();
// 사용자 스킬이 있으면 추가
var userFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
var hasUser = skills.Any(s =>
s.FilePath.StartsWith(userFolder, StringComparison.OrdinalIgnoreCase));
if (hasUser && !categories.Contains("사용자"))
categories.Add("사용자");
foreach (var cat in categories)
{
var btn = new Border
{
Tag = cat,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 5, 14, 5),
Margin = new Thickness(0, 0, 6, 0),
Background = cat == _selectedCategory
? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))
: TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
Cursor = Cursors.Hand,
};
btn.Child = new TextBlock
{
Text = cat,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = cat == _selectedCategory
? Brushes.White
: TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
};
btn.MouseLeftButtonUp += (s, _) =>
{
if (s is Border b && b.Tag is string tag)
{
_selectedCategory = tag;
BuildCategoryFilter();
RenderSkills();
}
};
CategoryFilterBar.Children.Add(btn);
}
}
// ─── 스킬 목록 렌더링 ──────────────────────────────────────────────────
private void RenderSkills()
{
SkillListPanel.Children.Clear();
var skills = FilterSkills(SkillService.Skills);
var userFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
foreach (var skill in skills)
SkillListPanel.Children.Add(BuildSkillCard(skill, userFolder));
if (skills.Count == 0)
SkillListPanel.Children.Add(new TextBlock
{
Text = "표시할 스킬이 없습니다.",
FontSize = 13,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(0, 20, 0, 0),
HorizontalAlignment = HorizontalAlignment.Center,
});
var total = SkillService.Skills.Count;
var avail = SkillService.Skills.Count(s => s.IsAvailable);
GalleryStatus.Text = $"총 {total}개 스킬 | 사용 가능 {avail}개 | 런타임 필요 {total - avail}개";
}
private List<SkillDefinition> FilterSkills(IReadOnlyList<SkillDefinition> skills)
{
var userFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "skills");
return _selectedCategory switch
{
"내장" => skills.Where(s => string.IsNullOrEmpty(s.Requires)
&& !s.FilePath.StartsWith(userFolder, StringComparison.OrdinalIgnoreCase)).ToList(),
"고급 (런타임)" => skills.Where(s => !string.IsNullOrEmpty(s.Requires)).ToList(),
"사용자" => skills.Where(s => s.FilePath.StartsWith(userFolder, StringComparison.OrdinalIgnoreCase)).ToList(),
_ => skills.ToList(),
};
}
private Border BuildSkillCard(SkillDefinition skill, string userFolder)
{
var bgBrush = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var isUser = skill.FilePath.StartsWith(userFolder, StringComparison.OrdinalIgnoreCase);
var isAdvanced = !string.IsNullOrEmpty(skill.Requires);
var card = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 0, 0, 6),
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) });
// ── 아이콘 원 ──
var iconBorder = new Border
{
Width = 38,
Height = 38,
CornerRadius = new CornerRadius(10),
Background = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC)),
Margin = new Thickness(0, 0, 12, 0),
VerticalAlignment = VerticalAlignment.Center,
};
iconBorder.Child = new TextBlock
{
Text = skill.Icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 16,
Foreground = skill.IsAvailable
? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))
: subBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(iconBorder, 0);
// ── 정보 ──
var infoPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
// 이름 + 뱃지들
var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 3) };
nameRow.Children.Add(new TextBlock
{
Text = $"/{skill.Name}",
FontSize = 13,
FontWeight = FontWeights.SemiBold,
FontFamily = new FontFamily("Consolas"),
Foreground = skill.IsAvailable
? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC))
: subBrush,
Opacity = skill.IsAvailable ? 1.0 : 0.6,
VerticalAlignment = VerticalAlignment.Center,
});
nameRow.Children.Add(new TextBlock
{
Text = $" {skill.Label}",
FontSize = 12.5,
Foreground = fgBrush,
VerticalAlignment = VerticalAlignment.Center,
});
// 소스/유형 뱃지
if (isUser)
nameRow.Children.Add(MakeBadge("사용자", "#34D399"));
else if (isAdvanced)
nameRow.Children.Add(MakeBadge("고급", "#A78BFA"));
else
nameRow.Children.Add(MakeBadge("내장", "#9CA3AF"));
if (skill.IsSample)
nameRow.Children.Add(MakeBadge("예제", "#F59E0B"));
// 비가용 뱃지
if (!skill.IsAvailable)
nameRow.Children.Add(MakeBadge(skill.UnavailableHint, "#F87171"));
infoPanel.Children.Add(nameRow);
infoPanel.Children.Add(new TextBlock
{
Text = skill.Description,
FontSize = 11.5,
Foreground = subBrush,
TextWrapping = TextWrapping.Wrap,
Opacity = skill.IsAvailable ? 1.0 : 0.6,
});
Grid.SetColumn(infoPanel, 1);
// ── 액션 버튼들 ──
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
};
// 편집
actions.Children.Add(MakeActionBtn("\uE70F", "#3B82F6", isUser ? "편집 (시각적 편집기)" : "편집 (파일 열기)",
() =>
{
if (isUser)
{
var editor = new SkillEditorWindow(skill) { Owner = this };
if (editor.ShowDialog() == true)
{
SkillService.LoadSkills();
BuildCategoryFilter();
RenderSkills();
}
}
else
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(skill.FilePath) { UseShellExecute = true }); }
catch (Exception ex) { CustomMessageBox.Show($"파일을 열 수 없습니다: {ex.Message}", "편집"); }
}
}));
// 복제 (사용자 스킬/폴더 스킬만)
actions.Children.Add(MakeActionBtn("\uE8C8", "#10B981", "복제",
() =>
{
try
{
var destFolder = Path.Combine(userFolder);
if (!Directory.Exists(destFolder)) Directory.CreateDirectory(destFolder);
var srcName = Path.GetFileNameWithoutExtension(skill.FilePath);
var destPath = Path.Combine(destFolder, $"{srcName}_copy.skill.md");
var counter = 2;
while (File.Exists(destPath))
destPath = Path.Combine(destFolder, $"{srcName}_copy{counter++}.skill.md");
File.Copy(skill.FilePath, destPath);
SkillService.LoadSkills();
BuildCategoryFilter();
RenderSkills();
}
catch (Exception ex) { CustomMessageBox.Show($"복제 실패: {ex.Message}", "복제"); }
}));
// 내보내기
actions.Children.Add(MakeActionBtn("\uEDE1", "#F59E0B", "내보내기 (.zip)",
() =>
{
var folderDlg = new System.Windows.Forms.FolderBrowserDialog
{ Description = "내보낼 폴더를 선택하세요" };
if (folderDlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
var result = SkillService.ExportSkill(skill, folderDlg.SelectedPath);
if (result != null)
CustomMessageBox.Show($"내보내기 완료:\n{result}", "내보내기");
else
CustomMessageBox.Show("내보내기에 실패했습니다.", "내보내기");
}));
// 삭제 (사용자 스킬만)
if (isUser)
{
actions.Children.Add(MakeActionBtn("\uE74D", "#F87171", "삭제",
() =>
{
var confirm = CustomMessageBox.Show(
$"스킬 '/{skill.Name}'을 삭제하시겠습니까?",
"스킬 삭제",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
try
{
File.Delete(skill.FilePath);
SkillService.LoadSkills();
BuildCategoryFilter();
RenderSkills();
}
catch (Exception ex) { CustomMessageBox.Show($"삭제 실패: {ex.Message}", "삭제"); }
}));
}
Grid.SetColumn(actions, 2);
grid.Children.Add(iconBorder);
grid.Children.Add(infoPanel);
grid.Children.Add(actions);
card.Child = grid;
// 호버 효과 + 카드 클릭 → 상세 보기
card.Cursor = Cursors.Hand;
card.MouseEnter += (_, _) =>
{
card.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
};
card.MouseLeave += (_, _) => card.Background = bgBrush;
card.MouseLeftButtonUp += (_, e) =>
{
// 액션 버튼 클릭은 무시 (버블링 방지)
if (e.OriginalSource is FrameworkElement src)
{
var parent = src;
while (parent != null)
{
if (parent == actions) return;
parent = VisualTreeHelper.GetParent(parent) as FrameworkElement;
}
}
ShowSkillDetail(skill);
};
return card;
}
private Border MakeBadge(string text, string colorHex)
{
var col = (Color)ColorConverter.ConvertFromString(colorHex);
return new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x25, col.R, col.G, col.B)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = text,
FontSize = 9.5,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(col),
},
};
}
private Border MakeActionBtn(string icon, string colorHex, string tooltip, Action action)
{
var col = (Color)ColorConverter.ConvertFromString(colorHex);
var btn = new Border
{
Width = 28,
Height = 28,
CornerRadius = new CornerRadius(6),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Margin = new Thickness(2, 0, 0, 0),
ToolTip = tooltip,
Child = new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = new SolidColorBrush(col),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
btn.MouseEnter += (_, _) =>
btn.Background = new SolidColorBrush(Color.FromArgb(0x20, col.R, col.G, col.B));
btn.MouseLeave += (_, _) => btn.Background = Brushes.Transparent;
btn.MouseLeftButtonUp += (_, _) => action();
return btn;
}
// ─── 스킬 상세 보기 팝업 ───────────────────────────────────────────────
private void ShowSkillDetail(SkillDefinition skill)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var subBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
var borderBr = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var popup = new Window
{
Title = $"/{skill.Name}",
Width = 580,
Height = 480,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
};
var outerBorder = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(12),
BorderBrush = borderBr,
BorderThickness = new Thickness(1, 1, 1, 1),
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 20,
ShadowDepth = 4,
Opacity = 0.3,
Color = Colors.Black,
},
};
var mainGrid = new Grid();
mainGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(44) });
mainGrid.RowDefinitions.Add(new RowDefinition());
// ── 타이틀바 ──
var titleBar = new Border
{
CornerRadius = new CornerRadius(12, 12, 0, 0),
Background = itemBg,
};
titleBar.MouseLeftButtonDown += (_, e) =>
{
var pos = e.GetPosition(popup);
if (pos.X > popup.ActualWidth - 50) return;
popup.DragMove();
};
var titleGrid = new Grid { Margin = new Thickness(16, 0, 12, 0) };
var titleLeft = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
titleLeft.Children.Add(new TextBlock
{
Text = skill.Icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
Foreground = new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 1, 8, 0),
});
titleLeft.Children.Add(new TextBlock
{
Text = $"/{skill.Name} {skill.Label}",
FontSize = 14,
FontWeight = FontWeights.SemiBold,
Foreground = fgBrush,
VerticalAlignment = VerticalAlignment.Center,
});
titleGrid.Children.Add(titleLeft);
var closeBtn = new Border
{
Width = 28, Height = 28,
CornerRadius = new CornerRadius(6),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
};
closeBtn.Child = new TextBlock
{
Text = "\uE8BB",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = subBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
closeBtn.MouseEnter += (s, _) => ((Border)s).Background =
new SolidColorBrush(Color.FromArgb(0x33, 0xFF, 0x44, 0x44));
closeBtn.MouseLeave += (s, _) => ((Border)s).Background = Brushes.Transparent;
closeBtn.MouseLeftButtonUp += (_, _) => popup.Close();
titleGrid.Children.Add(closeBtn);
titleBar.Child = titleGrid;
Grid.SetRow(titleBar, 0);
// ── 본문: 스킬 정보 + 프롬프트 미리보기 ──
var body = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Padding = new Thickness(20, 16, 20, 16),
};
var bodyPanel = new StackPanel();
// 메타 정보
var metaGrid = new Grid { Margin = new Thickness(0, 0, 0, 14) };
metaGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) });
metaGrid.ColumnDefinitions.Add(new ColumnDefinition());
void AddMetaRow(string label, string value, int row)
{
if (string.IsNullOrEmpty(value)) return;
metaGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var lb = new TextBlock
{
Text = label, FontSize = 11.5, Foreground = subBrush,
Margin = new Thickness(0, 2, 0, 2),
};
Grid.SetRow(lb, row); Grid.SetColumn(lb, 0);
metaGrid.Children.Add(lb);
var vb = new TextBlock
{
Text = value, FontSize = 11.5, Foreground = fgBrush,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 2, 0, 2),
};
Grid.SetRow(vb, row); Grid.SetColumn(vb, 1);
metaGrid.Children.Add(vb);
}
var metaRow = 0;
AddMetaRow("명령어", $"/{skill.Name}", metaRow++);
if (skill.IsSample)
AddMetaRow("유형", "예제", metaRow++);
AddMetaRow("라벨", skill.Label, metaRow++);
AddMetaRow("설명", skill.Description, metaRow++);
if (!string.IsNullOrEmpty(skill.Requires))
AddMetaRow("런타임", skill.Requires, metaRow++);
if (!string.IsNullOrEmpty(skill.AllowedTools))
AddMetaRow("허용 도구", skill.AllowedTools, metaRow++);
AddMetaRow("상태", skill.IsAvailable ? "✓ 사용 가능" : $"✗ {skill.UnavailableHint}", metaRow++);
AddMetaRow("경로", skill.FilePath, metaRow++);
bodyPanel.Children.Add(metaGrid);
// 구분선
bodyPanel.Children.Add(new Border
{
Height = 1,
Background = borderBr,
Margin = new Thickness(0, 4, 0, 12),
});
// 프롬프트 내용 미리보기
bodyPanel.Children.Add(new TextBlock
{
Text = "시스템 프롬프트 (미리보기)",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = subBrush,
Margin = new Thickness(0, 0, 0, 6),
});
var promptBorder = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 10, 12, 10),
};
var promptText = skill.SystemPrompt;
if (promptText.Length > 2000)
promptText = promptText[..2000] + "\n\n... (이하 생략)";
promptBorder.Child = new TextBlock
{
Text = promptText,
FontSize = 11.5,
FontFamily = new FontFamily("Consolas, Cascadia Code, Segoe UI"),
Foreground = fgBrush,
TextWrapping = TextWrapping.Wrap,
Opacity = 0.85,
};
bodyPanel.Children.Add(promptBorder);
body.Content = bodyPanel;
Grid.SetRow(body, 1);
mainGrid.Children.Add(titleBar);
mainGrid.Children.Add(body);
outerBorder.Child = mainGrid;
popup.Content = outerBorder;
popup.ShowDialog();
}
}

View File

@@ -0,0 +1,821 @@
<Window x:Class="AxCopilot.Views.StatisticsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:AxCopilot.ViewModels"
Title="AX Copilot — 사용 통계"
Width="800" Height="900"
MinWidth="680" MinHeight="700"
WindowStyle="None"
AllowsTransparency="True"
UseLayoutRounding="True"
SnapsToDevicePixels="True"
Background="Transparent"
ResizeMode="CanResize"
WindowStartupLocation="CenterScreen"
ShowInTaskbar="True"
Topmost="False"
Icon="pack://application:,,,/Assets/icon.ico"
MouseDown="Window_MouseDown">
<Grid Margin="20">
<Border CornerRadius="20" Background="White">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="22" ShadowDepth="4" Opacity="0.24"/>
</Border.Effect>
</Border>
<Border Background="White" CornerRadius="20" ClipToBounds="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="64"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ══ 헤더 ══ -->
<Border Grid.Row="0" CornerRadius="20,20,0,0">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#1A1B2E" Offset="0"/>
<GradientStop Color="#2B2D5B" Offset="0.5"/>
<GradientStop Color="#3B4ECC" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Grid Margin="24,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets"
FontSize="18" Foreground="#88AAFFCC"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<TextBlock Text="사용 통계" FontSize="17" FontWeight="SemiBold"
Foreground="White" VerticalAlignment="Center"/>
<TextBlock Text="최근 30일" FontSize="11" Foreground="#6688BBCC"
VerticalAlignment="Center" Margin="12,0,0,0"/>
</StackPanel>
<!-- 닫기 + 새로고침 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Width="32" Height="32" Background="Transparent" BorderThickness="0"
Cursor="Hand" Click="Refresh_Click" ToolTip="새로고침">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="8" Background="Transparent">
<TextBlock Text="&#xE72C;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#88AAFFCC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#22FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<Button Width="32" Height="32" Background="Transparent" BorderThickness="0"
Cursor="Hand" Click="Close_Click">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="8" Background="Transparent">
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#88AAFFCC"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#22FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</StackPanel>
</Grid>
</Border>
<!-- ══ 본문 ══ -->
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 탭 헤더 -->
<Border Grid.Row="0" Background="#F0F2F8" Padding="4" Margin="20,12,20,0" CornerRadius="10">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<RadioButton x:Name="StatsTabMain" Content="메인" IsChecked="True"
GroupName="StatsTab" Click="StatsTab_Click">
<RadioButton.Style>
<Style TargetType="RadioButton">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="Bd" CornerRadius="8" Padding="16,7" Cursor="Hand" Background="Transparent">
<TextBlock x:Name="Tb" Text="{TemplateBinding Content}" FontSize="12" FontWeight="SemiBold" Foreground="#8888AA" HorizontalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="White"/>
<Setter TargetName="Tb" Property="Foreground" Value="#1A1B2E"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</RadioButton.Style>
</RadioButton>
<RadioButton x:Name="StatsTabCommander" Content="AX Commander"
GroupName="StatsTab" Click="StatsTab_Click"
Style="{Binding Style, ElementName=StatsTabMain}"/>
<RadioButton x:Name="StatsTabAgent" Content="AX Agent"
GroupName="StatsTab" Click="StatsTab_Click"
Style="{Binding Style, ElementName=StatsTabMain}"/>
</StackPanel>
</Border>
<!-- ═══ 메인 탭 ═══ -->
<ScrollViewer x:Name="PanelMain" Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="24,14,24,24">
<!-- ── 요약 카드 4개 ── -->
<Grid Margin="0,0,0,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 오늘 -->
<Border Grid.Column="0" Background="#F5F7FF" CornerRadius="12" Padding="12,11">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#xE8D1;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#4B5EFC" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="오늘" FontSize="11" FontWeight="SemiBold" Foreground="#4B5EFC" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding TodayDate}" FontSize="10" Foreground="#9999BB"
Margin="0,0,0,2"/>
<TextBlock Text="{Binding TodaySummary}" FontSize="12" Foreground="#333355"
TextWrapping="Wrap" LineHeight="18"/>
</StackPanel>
</Border>
<!-- 이번 주 -->
<Border Grid.Column="2" Background="#F0FFF5" CornerRadius="12" Padding="12,11">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#xE787;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#107C10" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="주간" FontSize="11" FontWeight="SemiBold" Foreground="#107C10" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding WeekSummary}" FontSize="12" Foreground="#333355"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- 역대 최고 -->
<Border Grid.Column="4" Background="#FFFBF0" CornerRadius="12" Padding="12,11">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#xE734;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#C09010" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="최고 기록" FontSize="11" FontWeight="SemiBold" Foreground="#C09010" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding PeakDate}" FontSize="10" Foreground="#9999BB"
Margin="0,0,0,2"/>
<TextBlock Text="{Binding PeakDaySummary}" FontSize="12" Foreground="#333355"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- 30일 합계 -->
<Border Grid.Column="6" Background="#FFF0FF" CornerRadius="12" Padding="12,11">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,5">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#8B2FC9" Margin="0,0,5,0" VerticalAlignment="Center"/>
<TextBlock Text="30일" FontSize="11" FontWeight="SemiBold" Foreground="#8B2FC9" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding TotalOpensSummary}" FontSize="12" Foreground="#333355"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
<!-- ── 메인: AX Commander 14일 활동 ── -->
<Border Background="#F9FAFF" CornerRadius="14" Padding="18,12" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE721;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#4B5EFC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="AX Commander 호출 (14일)" FontSize="12" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding LauncherOpenBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<StackPanel Width="38" Margin="1,0" ToolTip="{Binding ToolTipText}">
<TextBlock Text="{Binding ValueLabel}" FontSize="8" Foreground="#9999BB"
HorizontalAlignment="Center" Margin="0,0,0,1"/>
<Border Height="52" VerticalAlignment="Bottom">
<Border CornerRadius="3,3,0,0" VerticalAlignment="Bottom"
Width="20"
Height="{Binding BarHeight}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#4B5EFC"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#5C6EFF"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#EEEEF8"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</Border>
<TextBlock Text="{Binding DayLabel}" FontSize="9" Foreground="#6B7280"
HorizontalAlignment="Center" Margin="0,2,0,0"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
<!-- ── 메인: AX Agent 14일 대화 ── -->
<Border Background="#F0F4FF" CornerRadius="14" Padding="18,12" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE8BD;" FontFamily="Segoe MDL2 Assets"
FontSize="13" Foreground="#818CF8" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="AX Agent 대화 (14일)" FontSize="12" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding ChatCountBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<StackPanel Width="38" Margin="1,0" ToolTip="{Binding ToolTipText}">
<TextBlock Text="{Binding ValueLabel}" FontSize="8" Foreground="#818CF8"
HorizontalAlignment="Center" Margin="0,0,0,1"/>
<Border Height="52" VerticalAlignment="Bottom">
<Border CornerRadius="3,3,0,0" VerticalAlignment="Bottom"
Width="20"
Height="{Binding BarHeight}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#818CF8"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#4B5EFC"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#E5E7EB"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</Border>
<TextBlock Text="{Binding DayLabel}" FontSize="9" Foreground="#6B7280"
HorizontalAlignment="Center" Margin="0,2,0,0"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ═══ AX Commander 탭 ═══ -->
<ScrollViewer x:Name="PanelCommander" Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Visibility="Collapsed">
<StackPanel Margin="24,14,24,24">
<!-- ── 런처 호출 횟수 차트 ── -->
<Border Background="#F9FAFF" CornerRadius="14" Padding="18,12" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE721;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#4B5EFC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="AX Commander 호출 횟수 (최근 14일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<!-- 차트 영역 -->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding LauncherOpenBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<Grid Width="42" Height="88" VerticalAlignment="Bottom" Margin="1,0"
ToolTip="{Binding ToolTipText, TargetNullValue={x:Null}}"
ToolTipService.IsEnabled="{Binding HasData}">
<!-- 값 레이블 -->
<TextBlock Text="{Binding ValueLabel}"
FontSize="9" Foreground="#9999BB"
HorizontalAlignment="Center"
VerticalAlignment="Top"/>
<!-- 막대 -->
<Border CornerRadius="4,4,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="24"
Height="{Binding BarHeight}"
Margin="0,0,0,18">
<Border.Background>
<SolidColorBrush>
<SolidColorBrush.Color>
<Color A="255" R="75" G="94" B="252"/>
</SolidColorBrush.Color>
</SolidColorBrush>
</Border.Background>
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#FF5C6EFF"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#EEEEF8"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<!-- 날짜 레이블 -->
<StackPanel VerticalAlignment="Bottom" HorizontalAlignment="Center">
<TextBlock Text="{Binding DayLabel}"
FontSize="10" Foreground="#6B7280"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding DateLabel}"
FontSize="8" Foreground="#9CA3AF"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
<!-- ── PC 활성 시간 차트 ── -->
<Border Background="#F9FAFF" CornerRadius="14" Padding="18,12" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE916;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#107C10" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="PC 활성 시간 (최근 14일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
<TextBlock Text=" · 잠금 해제 후 활동 시간" FontSize="11"
Foreground="#AAAACC" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding ActiveTimeBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<Grid Width="42" Height="88" VerticalAlignment="Bottom" Margin="1,0"
ToolTip="{Binding ToolTipText, TargetNullValue={x:Null}}"
ToolTipService.IsEnabled="{Binding HasData}">
<TextBlock Text="{Binding ValueLabel}"
FontSize="8" Foreground="#9999BB"
HorizontalAlignment="Center"
VerticalAlignment="Top"
TextTrimming="CharacterEllipsis"/>
<Border CornerRadius="4,4,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="24"
Height="{Binding BarHeight}"
Margin="0,0,0,18">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#107C10"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#15A815"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#EEEEF8"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<StackPanel VerticalAlignment="Bottom" HorizontalAlignment="Center">
<TextBlock Text="{Binding DayLabel}"
FontSize="10" Foreground="#6B7280"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding DateLabel}"
FontSize="8" Foreground="#9CA3AF"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
<!-- ── 인기 명령어 ── -->
<Border Background="#F9FAFF" CornerRadius="14" Padding="18,14" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE762;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#8B2FC9" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="자주 쓴 명령어 (최근 30일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TopCommands}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:CommandStatItem}">
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="22"/>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="44"/>
</Grid.ColumnDefinitions>
<!-- 순위 -->
<TextBlock Grid.Column="0" Text="{Binding Rank}"
FontSize="11" FontWeight="Bold" Foreground="#BBBBCC"
VerticalAlignment="Center"/>
<!-- 명령어 -->
<TextBlock Grid.Column="1" Text="{Binding Command}"
FontSize="12" FontFamily="Consolas"
Foreground="#2B3280" FontWeight="SemiBold"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<!-- 진행 바 -->
<Border Grid.Column="2" Height="8" CornerRadius="4"
Background="#EEEEF8" VerticalAlignment="Center">
<Border CornerRadius="4" Background="#8B2FC9"
HorizontalAlignment="Left"
Width="{Binding BarWidth}"/>
</Border>
<!-- 횟수 -->
<TextBlock Grid.Column="3" Text="{Binding Count}"
FontSize="11" Foreground="#9999BB"
HorizontalAlignment="Right"
VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- 데이터 없을 때 -->
<TextBlock FontSize="12" Foreground="#AAAACC"
HorizontalAlignment="Center" Margin="0,8">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding TopCommands.Count}" Value="0">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
아직 명령어 사용 데이터가 없습니다.
</TextBlock>
</StackPanel>
</Border>
<!-- ── 요일별 평균 호출 패턴 ── -->
<Border Background="#F5F7FF" CornerRadius="14" Padding="18,14" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE787;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#6366F1" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="요일별 평균 호출 (30일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
<TextBlock Text=" · 어느 요일에 가장 활발한지" FontSize="11"
Foreground="#AAAACC" VerticalAlignment="Center"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding WeekdayAvgBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<Grid Width="60" Height="100" VerticalAlignment="Bottom" Margin="4,0"
ToolTip="{Binding ToolTipText}">
<!-- 값 레이블 -->
<TextBlock Text="{Binding ValueLabel}"
FontSize="10" Foreground="#6366F1" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Top"/>
<!-- 막대 -->
<Border CornerRadius="6,6,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="36"
Height="{Binding BarHeight}"
Margin="0,0,0,22">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#C7D2FE"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#6366F1"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#EEEEF8"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
<!-- 요일 레이블 -->
<TextBlock Text="{Binding DayLabel}"
FontSize="12" FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Bottom">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="#9CA3AF"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Foreground" Value="#6366F1"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ═══ AX Agent 탭 ═══ -->
<ScrollViewer x:Name="PanelAgent" Grid.Row="1" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Visibility="Collapsed">
<StackPanel Margin="24,14,24,24">
<!-- ── AX Agent 대화 빈도 (탭별) ── -->
<Border Background="#F0F4FF" CornerRadius="14" Padding="18,16" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE8BD;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#4B5EFC" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="AX Agent 대화 빈도 (최근 14일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding AgentSummary}" FontSize="11" Foreground="#6B7280" Margin="0,0,0,10"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding ChatCountBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<StackPanel Width="42" Margin="2,0" ToolTip="{Binding ToolTipText}">
<TextBlock Text="{Binding ValueLabel}" FontSize="9" Foreground="#4B5EFC"
HorizontalAlignment="Center" Margin="0,0,0,2"/>
<Border Height="88" VerticalAlignment="Bottom" CornerRadius="4,4,0,0">
<Border CornerRadius="4,4,0,0" VerticalAlignment="Bottom"
Height="{Binding BarHeight}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#818CF8"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#4B5EFC"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#E5E7EB"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</Border>
<TextBlock Text="{Binding DayLabel}" FontSize="10" Foreground="#6B7280"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="{Binding DateLabel}" FontSize="8" Foreground="#9CA3AF"
HorizontalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
<!-- ── AX Agent 토큰 사용량 ── -->
<Border Background="#FFF7ED" CornerRadius="14" Padding="18,16" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#D97706" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="토큰 사용량 (최근 14일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding TokenUsageBars}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal"/></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:DayBarItem}">
<StackPanel Width="42" Margin="2,0" ToolTip="{Binding ToolTipText}">
<TextBlock Text="{Binding ValueLabel}" FontSize="9" Foreground="#D97706"
HorizontalAlignment="Center" Margin="0,0,0,2"/>
<Border Height="88" VerticalAlignment="Bottom" CornerRadius="4,4,0,0">
<Border CornerRadius="4,4,0,0" VerticalAlignment="Bottom"
Height="{Binding BarHeight}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#FBBF24"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsToday}" Value="True">
<Setter Property="Background" Value="#D97706"/>
</DataTrigger>
<DataTrigger Binding="{Binding HasData}" Value="False">
<Setter Property="Background" Value="#E5E7EB"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</Border>
<TextBlock Text="{Binding DayLabel}" FontSize="10" Foreground="#6B7280"
HorizontalAlignment="Center" Margin="0,4,0,0"/>
<TextBlock Text="{Binding DateLabel}" FontSize="8" Foreground="#9CA3AF"
HorizontalAlignment="Center"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
<!-- ── 탭별 대화 비율 (Chat/Cowork/Code) ── -->
<Border Background="#F5F0FF" CornerRadius="14" Padding="18,16" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#8B5CF6" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="탭별 대화 비율 (30일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TabChatRatios}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:TabRatioItem}">
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="65"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- 탭 이름 -->
<TextBlock Grid.Column="0" Text="{Binding TabName}"
FontSize="12" FontWeight="SemiBold"
Foreground="#333355" VerticalAlignment="Center"/>
<!-- 진행 바 -->
<Border Grid.Column="1" Height="14" CornerRadius="7"
Background="#EEEEF8" VerticalAlignment="Center">
<Border CornerRadius="7"
HorizontalAlignment="Left"
Width="{Binding BarWidth}"
Background="{Binding Color}"/>
</Border>
<!-- 비율 -->
<TextBlock Grid.Column="2" Text="{Binding PercentLabel}"
FontSize="11" Foreground="#9999BB"
VerticalAlignment="Center" Margin="10,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- ── 프롬프트/완료 토큰 비율 ── -->
<Border Background="#F0FFF5" CornerRadius="14" Padding="18,16" Margin="0,0,0,14">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#xE943;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#10B981" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="입력/출력 토큰 비율 (30일)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<TextBlock Text="{Binding TokenRatioSummary}" FontSize="11" Foreground="#6B7280" Margin="0,0,0,12"/>
<!-- 스택 바 -->
<Border CornerRadius="8" Height="24" ClipToBounds="True" Background="#E5E7EB" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal">
<Border Background="#06B6D4" CornerRadius="8,0,0,8"
Width="{Binding PromptBarWidth}"/>
<Border Background="#F59E0B" CornerRadius="0,8,8,0"
Width="{Binding CompletionBarWidth}"/>
</StackPanel>
</Border>
<!-- 범례 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Border Width="12" Height="12" CornerRadius="3" Background="#06B6D4" Margin="0,0,6,0"/>
<TextBlock Text="{Binding PromptTokenLabel}" FontSize="11" Foreground="#555577"
Margin="0,0,20,0" VerticalAlignment="Center"/>
<Border Width="12" Height="12" CornerRadius="3" Background="#F59E0B" Margin="0,0,6,0"/>
<TextBlock Text="{Binding CompletionTokenLabel}" FontSize="11" Foreground="#555577"
VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
</Border>
<!-- ── 즐겨찾기 상위 항목 ── -->
<Border Background="#FFF9F0" CornerRadius="14" Padding="18,16"
Visibility="{Binding HasFavorites, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<TextBlock Text="&#xE734;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#D97706" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="즐겨찾기 목록 (최근 추가순)" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1B2E" VerticalAlignment="Center"/>
</StackPanel>
<ItemsControl ItemsSource="{Binding TopFavorites}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:FavoriteStatItem}">
<Grid Margin="0,0,0,7">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="22"/>
<ColumnDefinition Width="22"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 순위 -->
<TextBlock Grid.Column="0" Text="{Binding Rank}"
FontSize="11" FontWeight="Bold" Foreground="#DDBB88"
VerticalAlignment="Center"/>
<!-- 아이콘 -->
<TextBlock Grid.Column="1" Text="{Binding Icon}"
FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="#D97706" VerticalAlignment="Center"/>
<!-- 이름 + 경로 -->
<StackPanel Grid.Column="2" VerticalAlignment="Center" Margin="4,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="SemiBold"
Foreground="#2B3280" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Path}" FontSize="10" FontFamily="Consolas"
Foreground="#AAAACC" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
<!-- ── 리사이즈 그립 ── -->
<Border Grid.Row="1"
HorizontalAlignment="Right" VerticalAlignment="Bottom"
Width="22" Height="22" Margin="0,0,4,4"
Cursor="SizeNWSE" Background="Transparent"
Panel.ZIndex="100"
MouseLeftButtonDown="ResizeGrip_MouseLeftButtonDown">
<Canvas Width="13" Height="13">
<Rectangle Canvas.Left="10" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#60444488"/>
<Rectangle Canvas.Left="6" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#40444488"/>
<Rectangle Canvas.Left="10" Canvas.Top="6" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#40444488"/>
<Rectangle Canvas.Left="2" Canvas.Top="10" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#25444488"/>
<Rectangle Canvas.Left="6" Canvas.Top="6" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#25444488"/>
<Rectangle Canvas.Left="10" Canvas.Top="2" Width="2.5" Height="2.5" RadiusX="1.2" RadiusY="1.2" Fill="#25444488"/>
</Canvas>
</Border>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,49 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using AxCopilot.ViewModels;
namespace AxCopilot.Views;
public partial class StatisticsWindow : Window
{
[DllImport("user32.dll")] private static extern void ReleaseCapture();
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCLBUTTONDOWN = 0xA1;
private const int HTBOTTOMRIGHT = 17;
private readonly StatisticsViewModel _vm;
public StatisticsWindow()
{
InitializeComponent();
_vm = new StatisticsViewModel();
DataContext = _vm;
}
private void Window_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed)
try { DragMove(); } catch { }
}
private void ResizeGrip_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState != MouseButtonState.Pressed) return;
ReleaseCapture();
SendMessage(new WindowInteropHelper(this).Handle, WM_NCLBUTTONDOWN, (IntPtr)HTBOTTOMRIGHT, IntPtr.Zero);
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private void Refresh_Click(object sender, RoutedEventArgs e) => _vm.Refresh();
private void StatsTab_Click(object sender, RoutedEventArgs e)
{
if (PanelMain == null || PanelCommander == null || PanelAgent == null) return;
PanelMain.Visibility = StatsTabMain.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
PanelCommander.Visibility = StatsTabCommander.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
PanelAgent.Visibility = StatsTabAgent.IsChecked == true ? Visibility.Visible : Visibility.Collapsed;
}
}

View File

@@ -0,0 +1,37 @@
<Window x:Class="AxCopilot.Views.TextActionPopup"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
Topmost="True"
ResizeMode="NoResize"
SizeToContent="WidthAndHeight"
Deactivated="Window_Deactivated"
KeyDown="Window_KeyDown">
<Border CornerRadius="12"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource AccentColor}"
BorderThickness="1"
Padding="6"
MinWidth="260">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="20" ShadowDepth="4" Opacity="0.3"/>
</Border.Effect>
<StackPanel>
<!-- 선택된 텍스트 미리보기 -->
<Border Background="{DynamicResource HintBackground}" CornerRadius="8" Padding="10,6" Margin="0,0,0,4">
<TextBlock x:Name="PreviewText"
FontSize="11" FontStyle="Italic"
Foreground="{DynamicResource SecondaryText}"
TextTrimming="CharacterEllipsis"
MaxWidth="300"/>
</Border>
<!-- 메뉴 항목들 -->
<StackPanel x:Name="MenuItems"/>
</StackPanel>
</Border>
</Window>

View File

@@ -0,0 +1,204 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace AxCopilot.Views;
/// <summary>
/// 텍스트가 선택된 상태에서 핫키를 누르면 커서 위치에 표시되는 액션 선택 팝업.
/// ↑↓ 화살표로 이동, Enter로 실행, Esc로 닫기.
/// </summary>
public partial class TextActionPopup : Window
{
[DllImport("user32.dll")]
private static extern bool GetCursorPos(out POINT lpPoint);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
public enum ActionResult { None, OpenLauncher, Translate, Summarize, GrammarFix, Explain, Rewrite }
public ActionResult SelectedAction { get; private set; } = ActionResult.None;
public string SelectedText { get; private set; } = "";
private int _selectedIndex;
private readonly List<(ActionResult Action, string Icon, string Label)> _items;
// 전체 명령 정의 (key → ActionResult, icon, label)
private static readonly (string Key, ActionResult Action, string Icon, string Label)[] AllCommands =
{
("translate", ActionResult.Translate, "\uE774", "번역"),
("summarize", ActionResult.Summarize, "\uE8D2", "요약"),
("grammar", ActionResult.GrammarFix, "\uE8AC", "문법 교정"),
("explain", ActionResult.Explain, "\uE946", "설명"),
("rewrite", ActionResult.Rewrite, "\uE70F", "다시 쓰기"),
};
/// <summary>사용 가능한 전체 명령 키와 라벨 목록.</summary>
public static IReadOnlyList<(string Key, string Label)> AvailableCommands
=> AllCommands.Select(c => (c.Key, c.Label)).ToList();
public TextActionPopup(string selectedText, List<string>? enabledCommands = null)
{
InitializeComponent();
SelectedText = selectedText;
var preview = selectedText.Replace("\r\n", " ").Replace("\n", " ");
PreviewText.Text = preview.Length > 60 ? preview[..57] + "…" : preview;
// 설정에서 활성화된 명령만 표시
var enabled = enabledCommands ?? AllCommands.Select(c => c.Key).ToList();
_items = new() { (ActionResult.OpenLauncher, "\uE8A7", "AX Commander 열기") };
foreach (var cmd in AllCommands)
{
if (enabled.Contains(cmd.Key, StringComparer.OrdinalIgnoreCase))
_items.Add((cmd.Action, cmd.Icon, cmd.Label));
}
BuildMenuItems();
PositionAtCursor();
Loaded += (_, _) =>
{
Focus();
UpdateSelection();
};
}
private void BuildMenuItems()
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
for (int i = 0; i < _items.Count; i++)
{
var (action, icon, label) = _items[i];
var idx = i;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0),
Width = 20,
TextAlignment = TextAlignment.Center,
});
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 13,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var border = new Border
{
Child = sp,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 14, 8),
Margin = new Thickness(0, 1, 0, 1),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Tag = idx,
};
border.MouseEnter += (s, _) =>
{
if (s is Border b && b.Tag is int n)
{
_selectedIndex = n;
UpdateSelection();
}
};
border.MouseLeftButtonUp += (_, _) =>
{
SelectedAction = action;
Close();
};
MenuItems.Children.Add(border);
}
}
private void UpdateSelection()
{
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var selectedBrush = TryFindResource("ItemSelectedBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x44, 0x4B, 0x5E, 0xFC));
for (int i = 0; i < MenuItems.Children.Count; i++)
{
if (MenuItems.Children[i] is Border b)
b.Background = i == _selectedIndex ? selectedBrush : Brushes.Transparent;
}
}
private void PositionAtCursor()
{
GetCursorPos(out var pt);
// DPI 보정
var source = PresentationSource.FromVisual(this);
double dpiX = 1.0, dpiY = 1.0;
if (source?.CompositionTarget != null)
{
dpiX = source.CompositionTarget.TransformFromDevice.M11;
dpiY = source.CompositionTarget.TransformFromDevice.M22;
}
Left = pt.X * dpiX;
Top = pt.Y * dpiY + 10; // 커서 아래쪽에 표시
// 화면 밖으로 나가지 않도록 보정
var screen = SystemParameters.WorkArea;
if (Left + 280 > screen.Right) Left = screen.Right - 280;
if (Top + 300 > screen.Bottom) Top = pt.Y * dpiY - 300; // 위쪽으로
if (Left < screen.Left) Left = screen.Left;
if (Top < screen.Top) Top = screen.Top;
}
private void Window_KeyDown(object sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Up:
_selectedIndex = (_selectedIndex - 1 + _items.Count) % _items.Count;
UpdateSelection();
e.Handled = true;
break;
case Key.Down:
_selectedIndex = (_selectedIndex + 1) % _items.Count;
UpdateSelection();
e.Handled = true;
break;
case Key.Enter:
SelectedAction = _items[_selectedIndex].Action;
Close();
e.Handled = true;
break;
case Key.Escape:
SelectedAction = ActionResult.None;
Close();
e.Handled = true;
break;
}
}
private void Window_Deactivated(object? sender, EventArgs e)
{
// 포커스를 잃으면 자동 닫기
if (IsVisible) Close();
}
}

View File

@@ -0,0 +1,373 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace AxCopilot.Views;
/// <summary>
/// 모던 카드 스타일 트레이 컨텍스트 메뉴 팩토리.
/// 배경색은 현재 WPF 테마 리소스를 실시간으로 읽어 적용합니다.
/// </summary>
internal static class TrayContextMenu
{
// Segoe MDL2 Assets 글리프 상수
internal const string GlyphOpen = "\uE7C5"; // Launch
internal const string GlyphSettings = "\uE713"; // Settings
internal const string GlyphReload = "\uE72C"; // Refresh
internal const string GlyphFolder = "\uE838"; // Folder
internal const string GlyphInfo = "\uE946"; // Info
internal const string GlyphStats = "\uE9D9"; // Chart
internal const string GlyphChat = "\uE8BD"; // Chat
internal const string GlyphGuide = "\uE736"; // ReadingList (사용 가이드)
internal const string GlyphAutoRun = "\uE82F"; // Lightbulb (전구)
internal const string GlyphExit = "\uE711"; // Cancel
// 1×1 투명 더미 이미지 — OnRenderItemImage 콜백을 강제로 트리거하기 위함
private static readonly System.Drawing.Bitmap DummyImage = new(1, 1);
/// <summary>
/// 현재 화면의 DPI 배율을 반환합니다 (100% = 1.0, 150% = 1.5).
/// WinForms는 논리 픽셀 값을 DPI 배율만큼 자동 확대하므로,
/// 이 값으로 역산하여 모든 PC에서 동일한 물리적 크기를 유지합니다.
/// </summary>
private static float GetDpiScale()
{
try
{
using var g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero);
return Math.Max(1f, g.DpiX / 96f);
}
catch { return 1f; }
}
/// <summary>논리 픽셀 값을 DPI 역산하여 물리적 크기가 동일하게 유지되도록 변환합니다.</summary>
private static int Dp(int logicalPx, float dpiScale) =>
Math.Max(1, (int)Math.Round(logicalPx / dpiScale));
/// <summary>외부에서 DPI 보정된 Padding을 생성할 때 사용합니다.</summary>
public static System.Windows.Forms.Padding DpiPadding(int left, int top, int right, int bottom)
{
var s = GetDpiScale();
return new System.Windows.Forms.Padding(Dp(left, s), Dp(top, s), Dp(right, s), Dp(bottom, s));
}
public static ToolStripMenuItem MakeItem(string text, string glyph, EventHandler onClick)
{
var s = GetDpiScale();
var item = new ToolStripMenuItem(text)
{
Tag = glyph,
Image = DummyImage, // Image가 있어야 OnRenderItemImage가 호출됨
Padding = new Padding(Dp(4, s), Dp(10, s), Dp(16, s), Dp(10, s)),
};
item.Click += onClick;
return item;
}
/// <summary>패딩이 적용된 ContextMenuStrip을 생성합니다.</summary>
public static ContextMenuStrip CreateMenu()
{
var s = GetDpiScale();
var menu = new ContextMenuStrip();
menu.Renderer = new ModernTrayRenderer();
// Point 단위 폰트는 DPI 자동 확대되므로 역산 적용
// Point 단위는 DPI와 무관하게 동일한 물리 크기 → 역산하지 않음
menu.Font = new Font("Segoe UI", 10f, GraphicsUnit.Point);
menu.ShowImageMargin = true;
// 너비를 넉넉히 잡아 좌측 아이콘~테두리 간 여백 확보
menu.ImageScalingSize = new Size(Dp(52, s), Dp(32, s));
menu.MinimumSize = new Size(Dp(280, s), 0);
// 팝업이 열릴 때 모서리를 둥글게 클리핑
menu.Opened += (_, _) => ApplyRoundedCorners(menu);
return menu;
}
private static void ApplyRoundedCorners(ContextMenuStrip menu)
{
try
{
var rgn = CreateRoundRectRgn(0, 0, menu.Width, menu.Height, 16, 16);
if (rgn == IntPtr.Zero) return;
// SetWindowRgn 성공 시 시스템이 핸들 소유권을 가져감 → DeleteObject 불필요
// 실패 시 직접 해제해야 GDI 누수 방지
if (SetWindowRgn(menu.Handle, rgn, true) == 0)
DeleteObject(rgn);
}
catch { /* 클리핑 실패 시 사각형으로 폴백 */ }
}
[DllImport("gdi32.dll")]
private static extern IntPtr CreateRoundRectRgn(int x1, int y1, int x2, int y2, int cx, int cy);
[DllImport("gdi32.dll")]
private static extern bool DeleteObject(IntPtr hObject);
[DllImport("user32.dll")]
private static extern int SetWindowRgn(IntPtr hWnd, IntPtr hRgn, bool bRedraw);
/// <summary>
/// 모든 아이템 추가 후 호출하여 상하 테두리 여백을 적용합니다.
/// WinForms ContextMenuStrip은 Padding/DefaultPadding/DisplayRectangle 모두
/// 내부 레이아웃이 무시하므로, 첫/마지막 아이템의 Margin으로 처리합니다.
/// </summary>
public static void ApplySpacing(ContextMenuStrip menu, int top = 10, int bottom = 10, int left = 0, int right = 0)
{
if (menu.Items.Count == 0) return;
var s = GetDpiScale();
int t = Dp(top, s), b = Dp(bottom, s), l = Dp(left, s), r = Dp(right, s);
foreach (ToolStripItem item in menu.Items)
{
var m = item.Margin;
item.Margin = new Padding(m.Left + l, m.Top, m.Right + r, m.Bottom);
}
var first = menu.Items[0];
first.Margin = new Padding(first.Margin.Left, t, first.Margin.Right, first.Margin.Bottom);
var last = menu.Items[menu.Items.Count - 1];
last.Margin = new Padding(last.Margin.Left, last.Margin.Top, last.Margin.Right, b);
}
}
// ─────────────────────────────────────────────────────────────────────────────
internal sealed class ModernTrayRenderer : ToolStripProfessionalRenderer
{
// 전구(자동 실행) 활성 색상 — 앰버 글로우
private static readonly Color BulbOnColor = Color.FromArgb(255, 185, 0);
private static readonly Color BulbGlowHalo = Color.FromArgb(50, 255, 185, 0);
public ModernTrayRenderer() : base(new ModernColorTable()) { }
// ─── 테마 색상 리더 ─────────────────────────────────────────────────
private static Color ThemeColor(string key, Color fallback)
{
try
{
if (System.Windows.Application.Current?.Resources[key]
is System.Windows.Media.SolidColorBrush b)
return Color.FromArgb(b.Color.A, b.Color.R, b.Color.G, b.Color.B);
}
catch { }
return fallback;
}
private static Color BgColor => ThemeColor("LauncherBackground", Color.FromArgb(250, 250, 252));
private static Color HoverColor => ThemeColor("ItemHoverBackground", Color.FromArgb(234, 234, 247));
private static Color AccentColor => ThemeColor("AccentColor", Color.FromArgb(75, 94, 252));
private static Color TextColor => ThemeColor("PrimaryText", Color.FromArgb(22, 23, 42));
private static Color TextDimColor => ThemeColor("SecondaryText", Color.FromArgb(90, 92, 128));
private static Color SepColor => ThemeColor("SeparatorColor", Color.FromArgb(228, 228, 242));
private static Color BorderColor => ThemeColor("BorderColor", Color.FromArgb(210, 210, 232));
private static Color IconColor => ThemeColor("SecondaryText", Color.FromArgb(110, 112, 148));
// ─── 배경 ────────────────────────────────────────────────────────────
protected override void OnRenderToolStripBackground(ToolStripRenderEventArgs e)
{
using var br = new SolidBrush(BgColor);
e.Graphics.FillRectangle(br, e.AffectedBounds);
}
protected override void OnRenderToolStripBorder(ToolStripRenderEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
using var pen = new Pen(BorderColor, 1f);
// ApplyRoundedCorners 의 clip radius(14) 보다 0.5px 안쪽에 그려야
// 창 경계 밖으로 삐져나오지 않음
var rect = new RectangleF(0.5f, 0.5f,
e.ToolStrip.Width - 1f,
e.ToolStrip.Height - 1f);
using var path = BuildRoundedPath(rect, 15f);
g.DrawPath(pen, path);
g.SmoothingMode = SmoothingMode.Default;
}
protected override void OnRenderImageMargin(ToolStripRenderEventArgs e)
{
// 거터 없음 — 전체 배경과 동일한 색
using var br = new SolidBrush(BgColor);
e.Graphics.FillRectangle(br, e.AffectedBounds);
}
// ─── 아이템 배경 ─────────────────────────────────────────────────────
protected override void OnRenderMenuItemBackground(ToolStripItemRenderEventArgs e)
{
var g = e.Graphics;
var item = e.Item;
// 기본 배경 (항상 메뉴 배경색으로 초기화)
using var bg = new SolidBrush(BgColor);
g.FillRectangle(bg, 0, 0, item.Width, item.Height);
if (!item.Selected || !item.Enabled) return;
// 호버: 안쪽에 둥근 모서리 하이라이트
g.SmoothingMode = SmoothingMode.AntiAlias;
using var hoverBr = new SolidBrush(HoverColor);
DrawRoundedFill(g, hoverBr, new RectangleF(6, 2, item.Width - 12, item.Height - 4), 8f);
g.SmoothingMode = SmoothingMode.Default;
}
// ─── 구분선 ──────────────────────────────────────────────────────────
protected override void OnRenderSeparator(ToolStripSeparatorRenderEventArgs e)
{
int y = e.Item.Height / 2;
using var pen = new Pen(SepColor);
e.Graphics.DrawLine(pen, 16, y, e.Item.Width - 16, y);
}
// ─── 텍스트 ──────────────────────────────────────────────────────────
protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
{
e.TextColor = e.Item.Enabled ? TextColor : TextDimColor;
e.TextFormat = TextFormatFlags.Left | TextFormatFlags.VerticalCenter;
base.OnRenderItemText(e);
}
// ─── 아이콘 (일반 아이템 + CheckOnClick 모두 처리) ───────────────────
protected override void OnRenderItemImage(ToolStripItemImageRenderEventArgs e)
{
// 모든 아이템의 글리프를 여기서 그린다
DrawGlyph(e.Graphics, e.Item, e.ImageRectangle);
}
protected override void OnRenderItemCheck(ToolStripItemImageRenderEventArgs e)
{
// CheckOnClick 항목: 기본 체크마크 대신 글리프 아이콘을 그린다
DrawGlyph(e.Graphics, e.Item, e.ImageRectangle);
}
// ─── 글리프 렌더러 ───────────────────────────────────────────────────
private static void DrawGlyph(Graphics g, ToolStripItem item, Rectangle bounds)
{
if (item.Tag is not string glyph || string.IsNullOrEmpty(glyph)) return;
if (bounds.Width <= 0 || bounds.Height <= 0) return;
g.TextRenderingHint = TextRenderingHint.AntiAlias;
bool isLightbulb = glyph == TrayContextMenu.GlyphAutoRun;
bool isChecked = item is ToolStripMenuItem { Checked: true };
Color glyphColor;
if (isLightbulb && isChecked)
{
// 전구 켜짐 — 앰버 글로우 효과
DrawLightbulbGlow(g, glyph, bounds);
glyphColor = BulbOnColor;
}
else if (isLightbulb && !isChecked)
{
// 전구 꺼짐 — 확실히 구분되는 진한 회색
glyphColor = Color.FromArgb(120, 120, 140);
}
else if (item.Selected && item.Enabled)
{
glyphColor = AccentColor;
}
else
{
glyphColor = IconColor;
}
// Point 단위는 DPI와 무관하게 동일한 물리 크기 → 역산하지 않음
using var font = new Font("Segoe MDL2 Assets", 12f, GraphicsUnit.Point);
using var br = new SolidBrush(glyphColor);
var fmt = StringFormat.GenericTypographic;
var size = g.MeasureString(glyph, font, 0, fmt);
// 넓은 아이콘 영역 내 중앙 정렬
float x = bounds.X + (bounds.Width - size.Width) / 2f + 2f;
float y = bounds.Y + (bounds.Height - size.Height) / 2f;
g.DrawString(glyph, font, br, x, y, fmt);
}
private static void DrawLightbulbGlow(Graphics g, string glyph, Rectangle bounds)
{
// 중심에서 균일하게 퍼지는 원형 앰버 글로우
g.SmoothingMode = SmoothingMode.AntiAlias;
float cx = bounds.X + bounds.Width / 2f;
float cy = bounds.Y + bounds.Height / 2f;
float radius = Math.Max(bounds.Width, bounds.Height) * 0.85f;
var glowRect = new RectangleF(cx - radius, cy - radius, radius * 2, radius * 2);
using var path = new GraphicsPath();
path.AddEllipse(glowRect);
using var brush = new PathGradientBrush(path)
{
CenterColor = Color.FromArgb(60, 255, 185, 0),
SurroundColors = new[] { Color.FromArgb(0, 255, 185, 0) },
CenterPoint = new PointF(cx, cy)
};
g.FillEllipse(brush, glowRect);
g.SmoothingMode = SmoothingMode.Default;
}
// ─── 유틸 ────────────────────────────────────────────────────────────
private static GraphicsPath BuildRoundedPath(RectangleF rect, float radius)
{
float d = radius * 2f;
var path = new GraphicsPath();
path.AddArc(rect.X, rect.Y, d, d, 180, 90);
path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90);
path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90);
path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90);
path.CloseFigure();
return path;
}
private static void DrawRoundedFill(Graphics g, Brush brush, RectangleF rect, float radius)
{
using var path = BuildRoundedPath(rect, radius);
g.FillPath(brush, path);
}
}
// ─────────────────────────────────────────────────────────────────────────────
internal sealed class ModernColorTable : ProfessionalColorTable
{
private static Color TC(string key, Color fb)
{
try
{
if (System.Windows.Application.Current?.Resources[key]
is System.Windows.Media.SolidColorBrush b)
return Color.FromArgb(b.Color.A, b.Color.R, b.Color.G, b.Color.B);
}
catch { }
return fb;
}
private static Color Bg => TC("LauncherBackground", Color.FromArgb(250, 250, 252));
private static Color Hover => TC("ItemHoverBackground", Color.FromArgb(234, 234, 247));
private static Color Border => TC("BorderColor", Color.FromArgb(210, 210, 232));
private static Color Sep => TC("SeparatorColor", Color.FromArgb(228, 228, 242));
public override Color MenuBorder => Border;
public override Color ToolStripDropDownBackground => Bg;
public override Color ImageMarginGradientBegin => Bg;
public override Color ImageMarginGradientMiddle => Bg;
public override Color ImageMarginGradientEnd => Bg;
public override Color MenuItemSelected => Hover;
public override Color MenuItemSelectedGradientBegin => Hover;
public override Color MenuItemSelectedGradientEnd => Hover;
public override Color MenuItemBorder => Color.Transparent;
public override Color MenuItemPressedGradientBegin => Hover;
public override Color MenuItemPressedGradientEnd => Hover;
public override Color SeparatorLight => Sep;
public override Color SeparatorDark => Sep;
public override Color CheckBackground => Color.Transparent;
public override Color CheckSelectedBackground => Color.Transparent;
public override Color CheckPressedBackground => Color.Transparent;
}

View File

@@ -0,0 +1,26 @@
<Window x:Class="AxCopilot.Views.TrayMenuWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
SizeToContent="WidthAndHeight"
Deactivated="Window_Deactivated">
<Border x:Name="RootBorder"
CornerRadius="12"
BorderThickness="1"
BorderBrush="{DynamicResource BorderColor}"
Background="{DynamicResource LauncherBackground}"
Padding="8 10"
MinWidth="240">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="4" Opacity="0.18" Color="Black"/>
</Border.Effect>
<StackPanel x:Name="MenuPanel" />
</Border>
</Window>

View File

@@ -0,0 +1,271 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
namespace AxCopilot.Views;
/// <summary>
/// WPF 기반 커스텀 트레이 컨텍스트 메뉴.
/// WinForms ContextMenuStrip의 DPI 불일치 문제를 근본 해결합니다.
/// </summary>
public partial class TrayMenuWindow : Window
{
private System.Windows.Threading.DispatcherTimer? _autoCloseTimer;
public TrayMenuWindow()
{
InitializeComponent();
// 마우스가 메뉴 밖으로 나가면 일정 시간 후 자동 닫힘
MouseLeave += (_, _) =>
{
_autoCloseTimer?.Stop();
_autoCloseTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(400)
};
_autoCloseTimer.Tick += (_, _) => { _autoCloseTimer.Stop(); Hide(); };
_autoCloseTimer.Start();
};
// 마우스가 다시 메뉴 위로 오면 타이머 취소
MouseEnter += (_, _) =>
{
_autoCloseTimer?.Stop();
};
}
// ─── 아이템 빌더 API ─────────────────────────────────────────────────
/// <summary>일반 메뉴 항목을 추가합니다.</summary>
public TrayMenuWindow AddItem(string glyph, string text, Action onClick)
{
var item = CreateItemBorder(glyph, text);
item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); };
MenuPanel.Children.Add(item);
return this;
}
/// <summary>일반 메뉴 항목을 추가하고 Border 참조를 반환합니다 (동적 가시성 제어용).</summary>
public TrayMenuWindow AddItem(string glyph, string text, Action onClick, out Border itemRef)
{
var item = CreateItemBorder(glyph, text);
item.MouseLeftButtonUp += (_, _) => { Hide(); onClick(); };
MenuPanel.Children.Add(item);
itemRef = item;
return this;
}
// 전구 아이콘 활성 색상 — 앰버(노란) 글로우
private static readonly Brush BulbOnBrush = new SolidColorBrush(Color.FromRgb(255, 185, 0));
private static readonly Brush BulbOffBrush = new SolidColorBrush(Color.FromRgb(120, 120, 140));
/// <summary>토글(체크) 메뉴 항목을 추가합니다.</summary>
public TrayMenuWindow AddToggleItem(string glyph, string text, bool initialChecked,
Action<bool> onToggle, out Func<bool> getChecked, out Action<string> setText)
{
bool isChecked = initialChecked;
var glyphBlock = new TextBlock
{
Text = glyph,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
Foreground = isChecked ? BulbOnBrush : BulbOffBrush,
VerticalAlignment = VerticalAlignment.Center,
Width = 20,
TextAlignment = TextAlignment.Center,
};
// 켜진 상태에서 글로우 이펙트 적용
if (isChecked)
glyphBlock.Effect = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Color.FromRgb(255, 185, 0), BlurRadius = 12, ShadowDepth = 0, Opacity = 0.7
};
var label = new TextBlock
{
Text = text,
FontSize = 13,
VerticalAlignment = VerticalAlignment.Center,
};
label.SetResourceReference(TextBlock.ForegroundProperty, "PrimaryText");
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Children = { glyphBlock, label },
};
var border = new Border
{
Child = panel,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 8, 16, 8),
Cursor = Cursors.Hand,
Background = Brushes.Transparent,
};
border.MouseEnter += (_, _) =>
border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
border.MouseLeave += (_, _) =>
border.Background = Brushes.Transparent;
border.MouseLeftButtonUp += (_, _) =>
{
isChecked = !isChecked;
glyphBlock.Foreground = isChecked ? BulbOnBrush : BulbOffBrush;
glyphBlock.Effect = isChecked
? new System.Windows.Media.Effects.DropShadowEffect
{ Color = Color.FromRgb(255, 185, 0), BlurRadius = 12, ShadowDepth = 0, Opacity = 0.7 }
: null;
onToggle(isChecked);
};
getChecked = () => isChecked;
setText = t => label.Text = t;
MenuPanel.Children.Add(border);
return this;
}
/// <summary>구분선을 추가합니다.</summary>
public TrayMenuWindow AddSeparator()
{
var sep = new Border
{
Height = 1,
Margin = new Thickness(16, 5, 16, 5),
};
sep.SetResourceReference(Border.BackgroundProperty, "SeparatorColor");
MenuPanel.Children.Add(sep);
return this;
}
// ─── 팝업 표시 ────────────────────────────────────────────────────────
/// <summary>트레이 아이콘 근처에 메뉴를 표시합니다.</summary>
public void ShowAtTray()
{
var workArea = SystemParameters.WorkArea;
// 먼저 표시하여 ActualWidth/ActualHeight 확정
Opacity = 0;
Show();
UpdateLayout();
double menuW = ActualWidth;
double menuH = ActualHeight;
// 마우스 커서 위치 (물리 픽셀 → WPF 논리 좌표)
var cursorPos = GetCursorPosition();
double dpiScale = VisualTreeHelper.GetDpi(this).PixelsPerDip;
double cx = cursorPos.X / dpiScale;
double cy = cursorPos.Y / dpiScale;
// 메뉴 우하단이 커서에서 살짝 떨어지도록 배치
double left = cx - menuW - 8;
double top = cy - menuH - 8;
// 화면 밖 보정
if (left < workArea.Left) left = workArea.Left + 4;
if (top < workArea.Top) top = workArea.Top + 4;
if (left + menuW > workArea.Right) left = workArea.Right - menuW - 4;
if (top + menuH > workArea.Bottom) top = workArea.Bottom - menuH - 4;
Left = left;
Top = top;
// 활성화하여 Deactivated 이벤트가 정상 작동하도록 보장
Activate();
// 페이드 인 애니메이션
var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(120))
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
BeginAnimation(OpacityProperty, fadeIn);
}
/// <summary>메뉴를 표시하기 직전에 동적 항목을 갱신할 수 있는 이벤트.</summary>
public event Action? Opening;
/// <summary>Opening 이벤트를 트리거하고 메뉴를 표시합니다.</summary>
public void ShowWithUpdate()
{
Opening?.Invoke();
ShowAtTray();
}
// ─── 내부 헬퍼 ────────────────────────────────────────────────────────
private Border CreateItemBorder(string glyph, string text)
{
var glyphBlock = new TextBlock
{
Text = glyph,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
VerticalAlignment = VerticalAlignment.Center,
Width = 20,
TextAlignment = TextAlignment.Center,
};
glyphBlock.SetResourceReference(TextBlock.ForegroundProperty, "SecondaryText");
var label = new TextBlock
{
Text = text,
FontSize = 13,
VerticalAlignment = VerticalAlignment.Center,
};
label.SetResourceReference(TextBlock.ForegroundProperty, "PrimaryText");
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Children = { glyphBlock, label },
};
var border = new Border
{
Child = panel,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 8, 16, 8),
Cursor = Cursors.Hand,
Background = Brushes.Transparent,
};
border.MouseEnter += (_, _) =>
{
border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
glyphBlock.Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
};
border.MouseLeave += (_, _) =>
{
border.Background = Brushes.Transparent;
glyphBlock.SetResourceReference(TextBlock.ForegroundProperty, "SecondaryText");
};
return border;
}
private void Window_Deactivated(object sender, EventArgs e)
{
Hide();
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out POINT lpPoint);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
private static POINT GetCursorPosition()
{
GetCursorPos(out var pt);
return pt;
}
}

View File

@@ -0,0 +1,345 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
namespace AxCopilot.Views;
/// <summary>
/// 에이전트가 사용자에게 질문할 때 표시하는 커스텀 대화 상자.
/// 선택지 버튼 + 직접 입력 텍스트 박스를 제공합니다.
/// </summary>
internal sealed class UserAskDialog : Window
{
private string _selectedResponse = "";
private readonly TextBox _customInput;
private readonly StackPanel _optionPanel;
public string SelectedResponse => _selectedResponse;
private UserAskDialog(string question, List<string> options, string defaultValue)
{
Width = 460;
MinWidth = 380;
MaxWidth = 560;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
Topmost = true;
var bgBrush = Application.Current.TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var primaryText = Application.Current.TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = Application.Current.TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = Application.Current.TryFindResource("AccentColor") as Brush
?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
var borderBrush = Application.Current.TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var itemBg = Application.Current.TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var hoverBg = Application.Current.TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
// 루트
var root = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(16),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(24, 20, 24, 18),
Effect = new DropShadowEffect
{
BlurRadius = 24, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
// 타이틀 바 (드래그 가능)
var titleBar = new Grid { Margin = new Thickness(0, 0, 0, 12) };
titleBar.MouseLeftButtonDown += (_, _) => { try { DragMove(); } catch { } };
var titleSp = new StackPanel { Orientation = Orientation.Horizontal };
titleSp.Children.Add(new TextBlock
{
Text = "\uE9CE",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 16,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
titleSp.Children.Add(new TextBlock
{
Text = "AX Agent — 질문",
FontSize = 14,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
titleBar.Children.Add(titleSp);
stack.Children.Add(titleBar);
// 구분선
stack.Children.Add(new Border
{
Height = 1,
Background = borderBrush,
Opacity = 0.3,
Margin = new Thickness(0, 0, 0, 14),
});
// 질문 텍스트
stack.Children.Add(new TextBlock
{
Text = question,
FontSize = 13.5,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 0, 0, 16),
LineHeight = 20,
});
// 선택지 버튼들
_optionPanel = new StackPanel { Margin = new Thickness(0, 0, 0, 12) };
Border? selectedBorder = null;
foreach (var option in options)
{
var capturedOption = option;
var optBorder = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(14, 10, 14, 10),
Margin = new Thickness(0, 0, 0, 6),
Cursor = Cursors.Hand,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(1.5),
};
var optSp = new StackPanel { Orientation = Orientation.Horizontal };
var radioCircle = new Border
{
Width = 18, Height = 18,
CornerRadius = new CornerRadius(9),
BorderBrush = secondaryText,
BorderThickness = new Thickness(1.5),
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new Border
{
Width = 10, Height = 10,
CornerRadius = new CornerRadius(5),
Background = Brushes.Transparent,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
},
};
optSp.Children.Add(radioCircle);
optSp.Children.Add(new TextBlock
{
Text = capturedOption,
FontSize = 13,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
TextWrapping = TextWrapping.Wrap,
});
optBorder.Child = optSp;
optBorder.MouseEnter += (s, _) =>
{
var b = (Border)s;
if (b.BorderBrush != accentBrush)
b.Background = hoverBg;
};
optBorder.MouseLeave += (s, _) =>
{
var b = (Border)s;
if (b.BorderBrush != accentBrush)
b.Background = itemBg;
};
optBorder.MouseLeftButtonUp += (s, _) =>
{
// 이전 선택 해제
if (selectedBorder != null)
{
selectedBorder.BorderBrush = Brushes.Transparent;
selectedBorder.Background = itemBg;
var prevCircle = FindRadioCircle(selectedBorder);
if (prevCircle != null) prevCircle.Background = Brushes.Transparent;
}
// 현재 선택
var cur = (Border)s;
cur.BorderBrush = accentBrush;
cur.Background = new SolidColorBrush(Color.FromArgb(0x15,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B));
var circle = FindRadioCircle(cur);
if (circle != null) circle.Background = accentBrush;
selectedBorder = cur;
_selectedResponse = capturedOption;
if (_customInput != null) _customInput.Text = ""; // 선택지 고르면 직접 입력 초기화
};
_optionPanel.Children.Add(optBorder);
}
stack.Children.Add(_optionPanel);
// 직접 입력 영역
stack.Children.Add(new TextBlock
{
Text = "또는 직접 입력:",
FontSize = 11.5,
Foreground = secondaryText,
Margin = new Thickness(2, 0, 0, 6),
});
_customInput = new TextBox
{
MinHeight = 40,
MaxHeight = 100,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 13,
Background = itemBg,
Foreground = primaryText,
CaretBrush = primaryText,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 8, 10, 8),
Text = defaultValue,
};
_customInput.TextChanged += (_, _) =>
{
if (!string.IsNullOrEmpty(_customInput.Text))
{
// 직접 입력 시 선택 해제
if (selectedBorder != null)
{
selectedBorder.BorderBrush = Brushes.Transparent;
selectedBorder.Background = itemBg;
var prevCircle = FindRadioCircle(selectedBorder);
if (prevCircle != null) prevCircle.Background = Brushes.Transparent;
selectedBorder = null;
}
_selectedResponse = _customInput.Text;
}
};
stack.Children.Add(_customInput);
// 하단 버튼
var btnPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 16, 0, 0),
};
// 확인 버튼
var confirmBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(20, 9, 20, 9),
Cursor = Cursors.Hand,
Margin = new Thickness(8, 0, 0, 0),
};
confirmBtn.Child = new TextBlock
{
Text = "확인",
FontSize = 13,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
};
confirmBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
confirmBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
confirmBtn.MouseLeftButtonUp += (_, _) =>
{
if (string.IsNullOrWhiteSpace(_selectedResponse) && !string.IsNullOrWhiteSpace(defaultValue))
_selectedResponse = defaultValue;
DialogResult = true;
Close();
};
btnPanel.Children.Add(confirmBtn);
// 취소 버튼
var cancelBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(10),
Padding = new Thickness(16, 9, 16, 9),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, 0xDC, 0x26, 0x26)),
BorderThickness = new Thickness(1),
};
cancelBtn.Child = new TextBlock
{
Text = "취소",
FontSize = 13,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
};
cancelBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
cancelBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
cancelBtn.MouseLeftButtonUp += (_, _) =>
{
_selectedResponse = "";
DialogResult = false;
Close();
};
// 취소를 왼쪽에 배치
btnPanel.Children.Insert(0, cancelBtn);
stack.Children.Add(btnPanel);
root.Child = stack;
Content = root;
// 등장 애니메이션
root.RenderTransformOrigin = new Point(0.5, 0.5);
root.RenderTransform = new ScaleTransform(0.95, 0.95);
root.Opacity = 0;
Loaded += (_, _) =>
{
var fade = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(150));
root.BeginAnimation(OpacityProperty, fade);
var scaleX = new DoubleAnimation(0.95, 1, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
var scaleY = new DoubleAnimation(0.95, 1, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } };
((ScaleTransform)root.RenderTransform).BeginAnimation(ScaleTransform.ScaleXProperty, scaleX);
((ScaleTransform)root.RenderTransform).BeginAnimation(ScaleTransform.ScaleYProperty, scaleY);
};
// ESC → 취소
KeyDown += (_, e) =>
{
if (e.Key == Key.Escape) { _selectedResponse = ""; DialogResult = false; Close(); }
if (e.Key == Key.Enter && !_customInput.IsFocused) { DialogResult = true; Close(); }
};
}
private static Border? FindRadioCircle(Border optionBorder)
{
if (optionBorder.Child is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is Border outer
&& outer.Child is Border inner)
return inner;
return null;
}
/// <summary>에이전트 질문 다이얼로그를 표시합니다.</summary>
/// <returns>사용자 응답 문자열. 취소 시 null.</returns>
public static string? Show(string question, List<string> options, string defaultValue = "")
{
var dlg = new UserAskDialog(question, options, defaultValue);
var result = dlg.ShowDialog();
return result == true ? dlg.SelectedResponse : null;
}
}

View File

@@ -0,0 +1,191 @@
<Window x:Class="AxCopilot.Views.WorkflowAnalyzerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Agent — 워크플로우 분석기" Width="560" Height="720"
MinWidth="420" MinHeight="400"
WindowStartupLocation="Manual"
WindowStyle="None" AllowsTransparency="True" Background="Transparent"
ResizeMode="CanResizeWithGrip">
<Border Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
CornerRadius="12" Margin="6"
>
<Border.Effect>
<DropShadowEffect BlurRadius="18" ShadowDepth="4" Opacity="0.3" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/> <!-- 타이틀 바 -->
<RowDefinition Height="Auto"/> <!-- 요약 카드 -->
<RowDefinition Height="Auto"/> <!-- 탭 바 -->
<RowDefinition Height="*"/> <!-- 콘텐츠 (타임라인 또는 병목 분석) -->
<RowDefinition Height="Auto"/> <!-- 상세 패널 -->
<RowDefinition Height="32"/> <!-- 상태 바 -->
</Grid.RowDefinitions>
<!-- ═══ 타이틀 바 ═══ -->
<Grid Grid.Row="0" Background="Transparent" MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="16,0,0,0">
<TextBlock Text="&#xE9D9;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="{DynamicResource AccentColor}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBlock Text="워크플로우 분석기" FontSize="14" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,8,0">
<Border x:Name="BtnClear" Width="32" Height="32" CornerRadius="6" Cursor="Hand"
Background="Transparent" ToolTip="초기화"
MouseLeftButtonUp="BtnClear_Click"
MouseEnter="TitleBtn_MouseEnter" MouseLeave="TitleBtn_MouseLeave">
<TextBlock Text="&#xE74D;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="{DynamicResource SecondaryText}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnMinimize" Width="32" Height="32" CornerRadius="6" Cursor="Hand"
Background="Transparent" ToolTip="최소화"
MouseLeftButtonUp="BtnMinimize_Click"
MouseEnter="TitleBtn_MouseEnter" MouseLeave="TitleBtn_MouseLeave">
<TextBlock Text="&#xE921;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnClose" Width="32" Height="32" CornerRadius="6" Cursor="Hand"
Background="Transparent" ToolTip="닫기"
MouseLeftButtonUp="BtnClose_Click"
MouseEnter="TitleBtn_MouseEnter" MouseLeave="TitleBtn_MouseLeave">
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="{DynamicResource SecondaryText}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</Grid>
<!-- ═══ 요약 카드 ═══ -->
<Border Grid.Row="1" Margin="12,0,12,6">
<UniformGrid Columns="4" Margin="0">
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8" Padding="10,8" Margin="2">
<StackPanel>
<TextBlock Text="소요 시간" FontSize="10" Foreground="{DynamicResource SecondaryText}"/>
<TextBlock x:Name="CardElapsed" Text="0.0s" FontSize="16" FontWeight="Bold" Foreground="#60A5FA"/>
</StackPanel>
</Border>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8" Padding="10,8" Margin="2">
<StackPanel>
<TextBlock Text="반복" FontSize="10" Foreground="{DynamicResource SecondaryText}"/>
<TextBlock x:Name="CardIterations" Text="0" FontSize="16" FontWeight="Bold" Foreground="#A78BFA"/>
</StackPanel>
</Border>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8" Padding="10,8" Margin="2"
ToolTip="입력 토큰 / 출력 토큰">
<StackPanel>
<TextBlock Text="토큰" FontSize="10" Foreground="{DynamicResource SecondaryText}"/>
<TextBlock x:Name="CardTokens" Text="0" FontSize="16" FontWeight="Bold" Foreground="#34D399"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<TextBlock Text="↑" FontSize="9" Foreground="#3B82F6" VerticalAlignment="Center"/>
<TextBlock x:Name="CardInputTokens" Text="0" FontSize="9" Foreground="#3B82F6" Margin="1,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="↓" FontSize="9" Foreground="#10B981" VerticalAlignment="Center"/>
<TextBlock x:Name="CardOutputTokens" Text="0" FontSize="9" Foreground="#10B981" Margin="1,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</StackPanel>
</Border>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8" Padding="10,8" Margin="2">
<StackPanel>
<TextBlock Text="도구 호출" FontSize="10" Foreground="{DynamicResource SecondaryText}"/>
<TextBlock x:Name="CardToolCalls" Text="0" FontSize="16" FontWeight="Bold" Foreground="#FBBF24"/>
</StackPanel>
</Border>
</UniformGrid>
</Border>
<!-- ═══ 탭 바 ═══ -->
<Border Grid.Row="2" Margin="12,0,12,6">
<StackPanel Orientation="Horizontal">
<Border x:Name="TabTimeline" CornerRadius="6" Padding="12,6" Margin="0,0,4,0"
Cursor="Hand" MouseLeftButtonUp="TabTimeline_Click">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="TabTimelineIcon" Text="&#xE916;" FontFamily="Segoe MDL2 Assets" FontSize="11"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock x:Name="TabTimelineText" Text="타임라인" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
<Border x:Name="TabBottleneck" CornerRadius="6" Padding="12,6" Margin="0,0,4,0"
Cursor="Hand" MouseLeftButtonUp="TabBottleneck_Click">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="TabBottleneckIcon" Text="&#xE9D2;" FontFamily="Segoe MDL2 Assets" FontSize="11"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock x:Name="TabBottleneckText" Text="병목 분석" FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" VerticalAlignment="Center"/>
</StackPanel>
</Border>
</StackPanel>
</Border>
<!-- ═══ 콘텐츠: 타임라인 ═══ -->
<ScrollViewer Grid.Row="3" x:Name="TimelineScroller" VerticalScrollBarVisibility="Auto"
Margin="12,0,12,0" Padding="0,0,4,0">
<StackPanel x:Name="TimelinePanel" Margin="0,0,0,8"/>
</ScrollViewer>
<!-- ═══ 콘텐츠: 병목 분석 ═══ -->
<ScrollViewer Grid.Row="3" x:Name="BottleneckScroller" VerticalScrollBarVisibility="Auto"
Margin="12,0,12,0" Padding="0,0,4,0" Visibility="Collapsed">
<StackPanel x:Name="BottleneckPanel" Margin="0,0,0,8">
<!-- 워터폴 차트 -->
<TextBlock Text="⏱ 실행 흐름 (워터폴)" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="4,4,0,8"/>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8"
Padding="12,8" Margin="0,0,0,12" MinHeight="60">
<Canvas x:Name="WaterfallCanvas" Height="120" ClipToBounds="True"/>
</Border>
<!-- 도구별 소요시간 -->
<TextBlock Text="🔧 도구별 소요시간" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="4,4,0,8"/>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8"
Padding="12,8" Margin="0,0,0,12">
<StackPanel x:Name="ToolTimePanel"/>
</Border>
<!-- 토큰 추세 -->
<TextBlock Text="📈 반복별 토큰 추세" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}" Margin="4,4,0,8"/>
<Border Background="{DynamicResource ItemBackground}" CornerRadius="8"
Padding="12,8" Margin="0,0,0,8" MinHeight="60">
<Canvas x:Name="TokenTrendCanvas" Height="100" ClipToBounds="True"/>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ═══ 상세 패널 ═══ -->
<Border Grid.Row="4" x:Name="DetailPanel" Visibility="Collapsed"
Background="{DynamicResource ItemBackground}" CornerRadius="8"
Margin="12,4,12,4" Padding="14,10" MaxHeight="180">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock x:Name="DetailTitle" Text="" FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"/>
<TextBlock x:Name="DetailBadge" Text="" FontSize="10" Foreground="White"
Margin="8,0,0,0" VerticalAlignment="Center" Padding="6,1"
Background="#34D399"/>
</StackPanel>
<TextBlock x:Name="DetailMeta" Text="" FontSize="11"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBlock x:Name="DetailContent" Text="" FontSize="11.5" TextWrapping="Wrap"
Foreground="{DynamicResource PrimaryText}" MaxHeight="100"
LineHeight="17"/>
</StackPanel>
</ScrollViewer>
</Border>
<!-- ═══ 상태 바 ═══ -->
<Border Grid.Row="5" Margin="12,0,12,6" ClipToBounds="True">
<Grid>
<TextBlock x:Name="StatusText" Text="대기 중..." FontSize="11"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" MaxWidth="400"/>
<TextBlock x:Name="LogLevelBadge" Text="" FontSize="10" HorizontalAlignment="Right"
Foreground="{DynamicResource SecondaryText}" VerticalAlignment="Center"
Padding="6,1" Opacity="0.7"/>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,928 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Shapes;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>
/// 에이전트 워크플로우 분석기.
/// 에이전트 실행 과정을 세로 타임라인 형태로 실시간 시각화합니다.
/// 개발자 모드 → 워크플로우 시각화 설정이 켜져있을 때 자동으로 열립니다.
/// </summary>
public partial class WorkflowAnalyzerWindow : Window
{
private readonly DateTime _startTime = DateTime.Now;
private int _totalToolCalls;
private int _totalInputTokens;
private int _totalOutputTokens;
private int _maxIteration;
private int _successCount;
private int _failCount;
private string _logLevel = "simple";
private bool _autoScroll = true;
private string _activeTab = "timeline"; // timeline | bottleneck
// 병목 분석 데이터 수집
private readonly List<WaterfallEntry> _waterfallEntries = new();
private readonly Dictionary<string, long> _toolTimeAccum = new();
private readonly List<(int Iteration, int Input, int Output)> _tokenTrend = new();
private long _lastEventMs; // 워터폴 시작 시간 기준
// ─── 상하좌우 리사이즈 (WindowStyle=None 대응) ─────────────
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int WM_NCHITTEST = 0x0084;
private const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14;
private const int HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
public WorkflowAnalyzerWindow()
{
InitializeComponent();
SourceInitialized += (_, _) =>
{
var hwndSource = (HwndSource)PresentationSource.FromVisual(this);
hwndSource?.AddHook(WndProc);
};
// 로그 레벨 표시
var app = System.Windows.Application.Current as App;
_logLevel = app?.SettingsService?.Settings.Llm.AgentLogLevel ?? "simple";
LogLevelBadge.Text = _logLevel switch
{
"debug" => "DEBUG",
"detailed" => "DETAILED",
_ => "SIMPLE",
};
// 시작 위치: 화면 우측 하단
Loaded += (_, _) =>
{
var screen = SystemParameters.WorkArea;
Left = screen.Right - ActualWidth - 20;
Top = screen.Bottom - ActualHeight - 20;
UpdateTabVisuals();
};
// 자동 스크롤 감지
TimelineScroller.ScrollChanged += (_, e) =>
{
_autoScroll = Math.Abs(e.ExtentHeight - e.ViewportHeight - e.VerticalOffset) < 30;
};
}
/// <summary>에이전트 이벤트를 수신하여 타임라인에 추가합니다.</summary>
public void OnAgentEvent(AgentEvent evt)
{
if (!Dispatcher.CheckAccess())
{
Dispatcher.Invoke(() => OnAgentEvent(evt));
return;
}
// 요약 카드 업데이트
if (evt.Iteration > _maxIteration) _maxIteration = evt.Iteration;
if (evt.InputTokens > 0) _totalInputTokens += evt.InputTokens;
if (evt.OutputTokens > 0) _totalOutputTokens += evt.OutputTokens;
if (evt.Type is AgentEventType.ToolCall)
_totalToolCalls++;
if (evt.Type is AgentEventType.ToolResult)
_successCount++;
if (evt.Type is AgentEventType.Error)
_failCount++;
UpdateSummaryCards();
// ── 병목 분석 데이터 수집 ──
CollectBottleneckData(evt);
// 타임라인 노드 추가
var node = CreateTimelineNode(evt);
TimelinePanel.Children.Add(node);
// 자동 스크롤
if (_autoScroll)
TimelineScroller.ScrollToEnd();
// 상태 바
StatusText.Text = evt.Type switch
{
AgentEventType.Complete => "✓ 완료",
AgentEventType.Error => $"⚠ 오류: {Truncate(evt.Summary, 60)}",
AgentEventType.ToolCall => $"실행 중: {evt.ToolName}",
AgentEventType.Thinking => "사고 중...",
AgentEventType.Planning => "계획 수립 중...",
_ => StatusText.Text,
};
// 완료 시 병목 분석 차트 자동 갱신
if (evt.Type == AgentEventType.Complete)
RenderBottleneckCharts();
}
/// <summary>타임라인을 초기화합니다.</summary>
public void Reset()
{
TimelinePanel.Children.Clear();
DetailPanel.Visibility = Visibility.Collapsed;
_totalToolCalls = 0;
_totalInputTokens = 0;
_totalOutputTokens = 0;
_maxIteration = 0;
_successCount = 0;
_failCount = 0;
_waterfallEntries.Clear();
_toolTimeAccum.Clear();
_tokenTrend.Clear();
_lastEventMs = 0;
UpdateSummaryCards();
ClearBottleneckCharts();
StatusText.Text = "대기 중...";
}
/// <summary>타임라인 탭을 활성화합니다.</summary>
public void SwitchToTimelineTab()
{
_activeTab = "timeline";
UpdateTabVisuals();
}
/// <summary>병목 분석 탭을 활성화합니다. 외부(ChatWindow)에서 호출합니다.</summary>
public void SwitchToBottleneckTab()
{
_activeTab = "bottleneck";
UpdateTabVisuals();
RenderBottleneckCharts();
}
// ─── 탭 전환 ──────────────────────────────────────────────────
private void TabTimeline_Click(object sender, MouseButtonEventArgs e)
{
_activeTab = "timeline";
UpdateTabVisuals();
}
private void TabBottleneck_Click(object sender, MouseButtonEventArgs e)
{
_activeTab = "bottleneck";
UpdateTabVisuals();
RenderBottleneckCharts();
}
private void UpdateTabVisuals()
{
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF));
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var isTimeline = _activeTab == "timeline";
TabTimeline.Background = isTimeline ? accentBrush : itemBg;
TabBottleneck.Background = !isTimeline ? accentBrush : itemBg;
// 활성 탭: 흰색 텍스트 (AccentColor 배경 위 가시성), 비활성: PrimaryText
TabTimelineIcon.Foreground = isTimeline ? Brushes.White : primaryText;
TabTimelineText.Foreground = isTimeline ? Brushes.White : primaryText;
TabBottleneckIcon.Foreground = !isTimeline ? Brushes.White : primaryText;
TabBottleneckText.Foreground = !isTimeline ? Brushes.White : primaryText;
TimelineScroller.Visibility = isTimeline ? Visibility.Visible : Visibility.Collapsed;
BottleneckScroller.Visibility = !isTimeline ? Visibility.Visible : Visibility.Collapsed;
DetailPanel.Visibility = isTimeline && DetailPanel.Tag != null
? Visibility.Visible : Visibility.Collapsed;
}
// ─── 병목 분석 데이터 수집 ──────────────────────────────────────
private record WaterfallEntry(string Label, long StartMs, long DurationMs, string Type, bool IsBottleneck = false);
private void CollectBottleneckData(AgentEvent evt)
{
var currentMs = (long)(evt.Timestamp - _startTime).TotalMilliseconds;
// 워터폴: LLM Thinking과 ToolResult (소요시간이 있는 이벤트)
if (evt.Type == AgentEventType.Thinking && evt.Iteration > 0)
{
// LLM 호출 시작 기록 (종료 시점은 다음 ToolCall/Complete 이벤트에서 계산)
_lastEventMs = currentMs;
}
else if (evt.Type == AgentEventType.ToolCall)
{
// 이전 LLM 사고 시간 = 현재 - 마지막 이벤트
if (_lastEventMs > 0)
{
var llmDuration = currentMs - _lastEventMs;
if (llmDuration > 100) // 100ms 미만은 노이즈
{
_waterfallEntries.Add(new WaterfallEntry(
$"LLM #{evt.Iteration}", _lastEventMs, llmDuration, "llm"));
}
}
_lastEventMs = currentMs;
}
else if (evt.Type == AgentEventType.ToolResult && evt.ElapsedMs > 0)
{
// 도구 실행 시간
var toolStart = currentMs - evt.ElapsedMs;
_waterfallEntries.Add(new WaterfallEntry(
evt.ToolName, toolStart, evt.ElapsedMs, "tool"));
// 도구별 누적
var key = evt.ToolName;
_toolTimeAccum[key] = _toolTimeAccum.GetValueOrDefault(key) + evt.ElapsedMs;
_lastEventMs = currentMs;
}
else if (evt.Type == AgentEventType.Complete && _lastEventMs > 0)
{
// 마지막 LLM 사고 시간
var llmDuration = currentMs - _lastEventMs;
if (llmDuration > 100)
{
_waterfallEntries.Add(new WaterfallEntry(
$"LLM (마무리)", _lastEventMs, llmDuration, "llm"));
}
}
// 토큰 추세
if ((evt.InputTokens > 0 || evt.OutputTokens > 0) && evt.Iteration > 0)
{
// 같은 반복의 기존 항목이 있으면 업데이트
var idx = _tokenTrend.FindIndex(t => t.Iteration == evt.Iteration);
if (idx >= 0)
{
var old = _tokenTrend[idx];
_tokenTrend[idx] = (evt.Iteration,
old.Input + evt.InputTokens,
old.Output + evt.OutputTokens);
}
else
{
_tokenTrend.Add((evt.Iteration, evt.InputTokens, evt.OutputTokens));
}
}
}
// ─── 병목 분석 차트 렌더링 ─────────────────────────────────────
private void ClearBottleneckCharts()
{
WaterfallCanvas.Children.Clear();
ToolTimePanel.Children.Clear();
TokenTrendCanvas.Children.Clear();
}
private void RenderBottleneckCharts()
{
ClearBottleneckCharts();
RenderWaterfallChart();
RenderToolTimeChart();
RenderTokenTrendChart();
}
/// <summary>워터폴 차트: LLM 호출과 도구 실행을 시간 순서로 표시. 병목 구간은 빨간색.</summary>
private void RenderWaterfallChart()
{
if (_waterfallEntries.Count == 0)
{
WaterfallCanvas.Height = 40;
var emptyMsg = new TextBlock
{
Text = "실행 데이터가 없습니다",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
};
Canvas.SetLeft(emptyMsg, 10);
Canvas.SetTop(emptyMsg, 10);
WaterfallCanvas.Children.Add(emptyMsg);
return;
}
// 병목 판정: 평균 × 2.0 이상 = 빨강, × 1.5 이상 = 주황
var avgDuration = _waterfallEntries.Average(e => e.DurationMs);
var maxEndMs = _waterfallEntries.Max(e => e.StartMs + e.DurationMs);
if (maxEndMs <= 0) maxEndMs = 1;
var rowHeight = 22;
var labelWidth = 90.0;
var chartHeight = _waterfallEntries.Count * rowHeight + 10;
WaterfallCanvas.Height = Math.Max(chartHeight, 60);
var canvasWidth = WaterfallCanvas.ActualWidth > 0 ? WaterfallCanvas.ActualWidth : 480;
var barAreaWidth = canvasWidth - labelWidth - 60; // 우측에 시간 텍스트 공간
for (int i = 0; i < _waterfallEntries.Count; i++)
{
var entry = _waterfallEntries[i];
var y = i * rowHeight + 4;
// 색상 결정
Color barColor;
if (entry.DurationMs >= avgDuration * 2.0)
barColor = Color.FromRgb(0xEF, 0x44, 0x44); // 빨강 (병목)
else if (entry.DurationMs >= avgDuration * 1.5)
barColor = Color.FromRgb(0xF9, 0x73, 0x16); // 주황 (주의)
else if (entry.Type == "llm")
barColor = Color.FromRgb(0x60, 0xA5, 0xFA); // 파랑 (LLM)
else
barColor = Color.FromRgb(0x34, 0xD3, 0x99); // 녹색 (도구)
// 라벨
var label = new TextBlock
{
Text = Truncate(entry.Label, 12),
FontSize = 10,
FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush(barColor),
Width = labelWidth,
TextAlignment = TextAlignment.Right,
};
Canvas.SetLeft(label, 0);
Canvas.SetTop(label, y + 2);
WaterfallCanvas.Children.Add(label);
// 바
var barStart = (entry.StartMs / (double)maxEndMs) * barAreaWidth;
var barWidth = Math.Max((entry.DurationMs / (double)maxEndMs) * barAreaWidth, 3);
var bar = new Rectangle
{
Width = barWidth,
Height = 14,
RadiusX = 3, RadiusY = 3,
Fill = new SolidColorBrush(barColor),
Opacity = 0.85,
ToolTip = $"{entry.Label}: {FormatMs(entry.DurationMs)}",
};
Canvas.SetLeft(bar, labelWidth + 8 + barStart);
Canvas.SetTop(bar, y + 1);
WaterfallCanvas.Children.Add(bar);
// 시간 텍스트
var timeText = new TextBlock
{
Text = FormatMs(entry.DurationMs),
FontSize = 9,
Foreground = new SolidColorBrush(barColor),
FontFamily = new FontFamily("Consolas"),
};
Canvas.SetLeft(timeText, labelWidth + 8 + barStart + barWidth + 4);
Canvas.SetTop(timeText, y + 3);
WaterfallCanvas.Children.Add(timeText);
// 병목 아이콘
if (entry.DurationMs >= avgDuration * 2.0)
{
var icon = new TextBlock
{
Text = "🔴",
FontSize = 9,
};
Canvas.SetLeft(icon, labelWidth + 8 + barStart + barWidth + 44);
Canvas.SetTop(icon, y + 2);
WaterfallCanvas.Children.Add(icon);
}
}
}
/// <summary>도구별 누적 소요시간 수평 바 차트.</summary>
private void RenderToolTimeChart()
{
ToolTimePanel.Children.Clear();
if (_toolTimeAccum.Count == 0)
{
ToolTimePanel.Children.Add(new TextBlock
{
Text = "도구 실행 데이터가 없습니다",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(0, 4, 0, 4),
});
return;
}
// LLM 시간 합산
var llmTime = _waterfallEntries
.Where(e => e.Type == "llm")
.Sum(e => e.DurationMs);
var allEntries = _toolTimeAccum
.Select(kv => (Name: kv.Key, Ms: kv.Value))
.ToList();
if (llmTime > 0)
allEntries.Add(("LLM 호출", llmTime));
var sorted = allEntries.OrderByDescending(e => e.Ms).ToList();
var maxMs = sorted.Max(e => e.Ms);
if (maxMs <= 0) maxMs = 1;
foreach (var (name, ms) in sorted)
{
var isBottleneck = ms == sorted[0].Ms;
var barColor = name == "LLM 호출"
? Color.FromRgb(0x60, 0xA5, 0xFA)
: isBottleneck
? Color.FromRgb(0xEF, 0x44, 0x44)
: Color.FromRgb(0x34, 0xD3, 0x99);
var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(60) });
// 도구명
var nameText = new TextBlock
{
Text = Truncate(name, 12),
FontSize = 11,
FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush(barColor),
TextAlignment = TextAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
};
Grid.SetColumn(nameText, 0);
// 바
var barWidth = (ms / (double)maxMs);
var barBorder = new Border
{
Background = new SolidColorBrush(barColor),
CornerRadius = new CornerRadius(3),
Height = 14,
HorizontalAlignment = HorizontalAlignment.Left,
Width = 0, // 나중에 SizeChanged에서 설정
Opacity = 0.8,
};
var barContainer = new Border { Margin = new Thickness(0, 2, 0, 2) };
barContainer.Child = barBorder;
barContainer.SizeChanged += (_, _) =>
{
barBorder.Width = Math.Max(barContainer.ActualWidth * barWidth, 4);
};
Grid.SetColumn(barContainer, 1);
// 시간
var timeText = new TextBlock
{
Text = FormatMs(ms),
FontSize = 10,
Foreground = new SolidColorBrush(barColor),
FontFamily = new FontFamily("Consolas"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
};
Grid.SetColumn(timeText, 2);
row.Children.Add(nameText);
row.Children.Add(barContainer);
row.Children.Add(timeText);
ToolTimePanel.Children.Add(row);
}
}
/// <summary>반복별 토큰 추세 꺾은선 그래프.</summary>
private void RenderTokenTrendChart()
{
TokenTrendCanvas.Children.Clear();
if (_tokenTrend.Count < 2)
{
TokenTrendCanvas.Height = 40;
TokenTrendCanvas.Children.Add(new TextBlock
{
Text = _tokenTrend.Count == 0 ? "토큰 데이터가 없습니다" : "2회 이상 반복이 필요합니다",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
});
Canvas.SetLeft(TokenTrendCanvas.Children[0], 10);
Canvas.SetTop(TokenTrendCanvas.Children[0], 10);
return;
}
TokenTrendCanvas.Height = 100;
var canvasWidth = TokenTrendCanvas.ActualWidth > 0 ? TokenTrendCanvas.ActualWidth : 480;
var canvasHeight = 90.0;
var marginLeft = 45.0;
var marginRight = 10.0;
var chartWidth = canvasWidth - marginLeft - marginRight;
var sorted = _tokenTrend.OrderBy(t => t.Iteration).ToList();
var maxToken = sorted.Max(t => Math.Max(t.Input, t.Output));
if (maxToken <= 0) maxToken = 1;
// Y축 눈금
for (int i = 0; i <= 2; i++)
{
var y = canvasHeight * (1 - i / 2.0);
var val = maxToken * i / 2;
var gridLine = new Line
{
X1 = marginLeft, Y1 = y,
X2 = canvasWidth - marginRight, Y2 = y,
Stroke = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
StrokeThickness = 0.5,
Opacity = 0.4,
};
TokenTrendCanvas.Children.Add(gridLine);
var yLabel = new TextBlock
{
Text = val >= 1000 ? $"{val / 1000.0:F0}k" : val.ToString(),
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextAlignment = TextAlignment.Right,
Width = marginLeft - 6,
};
Canvas.SetLeft(yLabel, 0);
Canvas.SetTop(yLabel, y - 6);
TokenTrendCanvas.Children.Add(yLabel);
}
// 입력 토큰 선 (파랑)
DrawPolyline(sorted.Select((t, i) => new Point(
marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth,
canvasHeight * (1 - t.Input / (double)maxToken)
)).ToList(), "#3B82F6");
// 출력 토큰 선 (녹색)
DrawPolyline(sorted.Select((t, i) => new Point(
marginLeft + (i / (double)(sorted.Count - 1)) * chartWidth,
canvasHeight * (1 - t.Output / (double)maxToken)
)).ToList(), "#10B981");
// X축 라벨
foreach (var (iter, _, _) in sorted)
{
var idx = sorted.FindIndex(t => t.Iteration == iter);
var x = marginLeft + (idx / (double)(sorted.Count - 1)) * chartWidth;
var xLabel = new TextBlock
{
Text = $"#{iter}",
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
};
Canvas.SetLeft(xLabel, x - 8);
Canvas.SetTop(xLabel, canvasHeight + 2);
TokenTrendCanvas.Children.Add(xLabel);
}
// 범례
var legend = new StackPanel { Orientation = Orientation.Horizontal };
legend.Children.Add(CreateLegendDot("#3B82F6", "입력"));
legend.Children.Add(CreateLegendDot("#10B981", "출력"));
Canvas.SetRight(legend, 10);
Canvas.SetTop(legend, 0);
TokenTrendCanvas.Children.Add(legend);
// 컨텍스트 폭발 경고
if (sorted.Count >= 3)
{
var inputValues = sorted.Select(t => t.Input).ToList();
bool increasing = true;
for (int i = 1; i < inputValues.Count; i++)
{
if (inputValues[i] <= inputValues[i - 1]) { increasing = false; break; }
}
if (increasing && inputValues[^1] > inputValues[0] * 2)
{
var warning = new TextBlock
{
Text = "⚠ 컨텍스트 크기 급증",
FontSize = 10,
Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
FontWeight = FontWeights.SemiBold,
};
Canvas.SetLeft(warning, marginLeft + 4);
Canvas.SetTop(warning, 0);
TokenTrendCanvas.Children.Add(warning);
}
}
}
private void DrawPolyline(List<Point> points, string colorHex)
{
if (points.Count < 2) return;
var color = (Color)ColorConverter.ConvertFromString(colorHex);
var polyline = new Polyline
{
Stroke = new SolidColorBrush(color),
StrokeThickness = 2,
StrokeLineJoin = PenLineJoin.Round,
};
foreach (var p in points)
polyline.Points.Add(p);
TokenTrendCanvas.Children.Add(polyline);
// 점 찍기
foreach (var p in points)
{
var dot = new Ellipse
{
Width = 6, Height = 6,
Fill = new SolidColorBrush(color),
};
Canvas.SetLeft(dot, p.X - 3);
Canvas.SetTop(dot, p.Y - 3);
TokenTrendCanvas.Children.Add(dot);
}
}
private static StackPanel CreateLegendDot(string colorHex, string text)
{
var color = (Color)ColorConverter.ConvertFromString(colorHex);
var sp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(8, 0, 0, 0) };
sp.Children.Add(new Ellipse
{
Width = 6, Height = 6,
Fill = new SolidColorBrush(color),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 3, 0),
});
sp.Children.Add(new TextBlock
{
Text = text,
FontSize = 9,
Foreground = new SolidColorBrush(color),
VerticalAlignment = VerticalAlignment.Center,
});
return sp;
}
// ─── 타임라인 노드 생성 ────────────────────────────────────────
private Border CreateTimelineNode(AgentEvent evt)
{
var (icon, iconColor, label) = GetEventVisual(evt);
var node = new Border
{
Background = Brushes.Transparent,
Margin = new Thickness(0, 1, 0, 1),
Padding = new Thickness(8, 6, 8, 6),
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Tag = evt,
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
// 아이콘 원
var iconBorder = new Border
{
Width = 22, Height = 22,
CornerRadius = new CornerRadius(11),
Background = new SolidColorBrush(iconColor),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 2, 0, 0),
};
iconBorder.Child = new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(iconBorder, 0);
grid.Children.Add(iconBorder);
// 타임라인 세로 선
var line = new Rectangle
{
Width = 2,
Fill = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Stretch,
Opacity = 0.4,
};
Grid.SetColumn(line, 1);
grid.Children.Add(line);
// 내용
var content = new StackPanel { Margin = new Thickness(8, 0, 0, 0) };
var headerPanel = new StackPanel { Orientation = Orientation.Horizontal };
headerPanel.Children.Add(new TextBlock
{
Text = label,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
VerticalAlignment = VerticalAlignment.Center,
});
if (evt.ElapsedMs > 0)
{
headerPanel.Children.Add(new TextBlock
{
Text = evt.ElapsedMs >= 1000 ? $" {evt.ElapsedMs / 1000.0:F1}s" : $" {evt.ElapsedMs}ms",
FontSize = 10,
Foreground = evt.ElapsedMs > 3000
? Brushes.OrangeRed
: TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
}
headerPanel.Children.Add(new TextBlock
{
Text = $" {evt.Timestamp:HH:mm:ss}",
FontSize = 9,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Opacity = 0.6,
});
content.Children.Add(headerPanel);
if (!string.IsNullOrEmpty(evt.Summary))
{
content.Children.Add(new TextBlock
{
Text = Truncate(evt.Summary, 120),
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 2, 0, 0),
LineHeight = 16,
});
}
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
{
var tokenPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 0) };
tokenPanel.Children.Add(CreateBadge($"↑{evt.InputTokens:#,0}", "#3B82F6"));
tokenPanel.Children.Add(CreateBadge($"↓{evt.OutputTokens:#,0}", "#10B981"));
content.Children.Add(tokenPanel);
}
Grid.SetColumn(content, 2);
grid.Children.Add(content);
node.Child = grid;
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
node.MouseEnter += (_, _) => node.Background = hoverBrush;
node.MouseLeave += (_, _) => node.Background = Brushes.Transparent;
node.MouseLeftButtonUp += (_, _) => ShowDetail(evt);
return node;
}
private static (string Icon, Color Color, string Label) GetEventVisual(AgentEvent evt)
{
return evt.Type switch
{
AgentEventType.Thinking => ("\uE7BA", Color.FromRgb(0x60, 0xA5, 0xFA), "사고"),
AgentEventType.Planning => ("\uE9D5", Color.FromRgb(0xA7, 0x8B, 0xFA), "계획"),
AgentEventType.StepStart => ("\uE72A", Color.FromRgb(0x60, 0xA5, 0xFA), $"단계 {evt.StepCurrent}/{evt.StepTotal}"),
AgentEventType.StepDone => ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"단계 완료"),
AgentEventType.ToolCall => ("\uE756", Color.FromRgb(0xFB, 0xBF, 0x24), evt.ToolName),
AgentEventType.ToolResult=> ("\uE73E", Color.FromRgb(0x34, 0xD3, 0x99), $"{evt.ToolName} ✓"),
AgentEventType.SkillCall => ("\uE768", Color.FromRgb(0xA7, 0x8B, 0xFA), $"스킬: {evt.ToolName}"),
AgentEventType.Error => ("\uE783", Color.FromRgb(0xF8, 0x71, 0x71), $"{evt.ToolName} ✗"),
AgentEventType.Complete => ("\uE930", Color.FromRgb(0x34, 0xD3, 0x99), "완료"),
AgentEventType.Decision => ("\uE8C8", Color.FromRgb(0xFB, 0xBF, 0x24), "의사결정"),
_ => ("\uE946", Color.FromRgb(0x6B, 0x72, 0x80), "이벤트"),
};
}
private static Border CreateBadge(string text, string colorHex)
{
var color = (Color)ColorConverter.ConvertFromString(colorHex);
return new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x30, color.R, color.G, color.B)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(0, 0, 4, 0),
Child = new TextBlock
{
Text = text,
FontSize = 9,
Foreground = new SolidColorBrush(color),
FontWeight = FontWeights.SemiBold,
},
};
}
// ─── 상세 패널 ─────────────────────────────────────────────
private void ShowDetail(AgentEvent evt)
{
DetailPanel.Visibility = Visibility.Visible;
DetailPanel.Tag = evt;
var (_, color, label) = GetEventVisual(evt);
DetailTitle.Text = label;
DetailBadge.Text = evt.Success ? "성공" : "실패";
DetailBadge.Background = new SolidColorBrush(evt.Success
? Color.FromRgb(0x34, 0xD3, 0x99)
: Color.FromRgb(0xF8, 0x71, 0x71));
var meta = $"시간: {evt.Timestamp:HH:mm:ss.fff}";
if (evt.ElapsedMs > 0)
meta += $" | 소요: {(evt.ElapsedMs >= 1000 ? $"{evt.ElapsedMs / 1000.0:F1}s" : $"{evt.ElapsedMs}ms")}";
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
meta += $" | 토큰: 입력 {evt.InputTokens:#,0} / 출력 {evt.OutputTokens:#,0}";
if (evt.Iteration > 0)
meta += $" | 반복 #{evt.Iteration}";
DetailMeta.Text = meta;
var contentText = evt.Summary ?? "";
if (!string.IsNullOrEmpty(evt.ToolInput))
contentText += $"\n\n파라미터:\n{Truncate(evt.ToolInput, 500)}";
if (!string.IsNullOrEmpty(evt.FilePath))
contentText += $"\n파일: {evt.FilePath}";
DetailContent.Text = contentText;
}
// ─── 요약 카드 업데이트 ──────────────────────────────────────
private void UpdateSummaryCards()
{
var elapsed = (DateTime.Now - _startTime).TotalSeconds;
CardElapsed.Text = elapsed >= 60 ? $"{elapsed / 60:F0}m {elapsed % 60:F0}s" : $"{elapsed:F1}s";
CardIterations.Text = _maxIteration.ToString();
var totalTokens = _totalInputTokens + _totalOutputTokens;
CardTokens.Text = totalTokens >= 1000 ? $"{totalTokens / 1000.0:F1}k" : totalTokens.ToString();
CardInputTokens.Text = _totalInputTokens >= 1000 ? $"{_totalInputTokens / 1000.0:F1}k" : _totalInputTokens.ToString();
CardOutputTokens.Text = _totalOutputTokens >= 1000 ? $"{_totalOutputTokens / 1000.0:F1}k" : _totalOutputTokens.ToString();
CardToolCalls.Text = _totalToolCalls.ToString();
}
// ─── 유틸리티 ────────────────────────────────────────────────
private static string FormatMs(long ms)
=> ms >= 1000 ? $"{ms / 1000.0:F1}s" : $"{ms}ms";
// ─── 윈도우 이벤트 ────────────────────────────────────────────
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// 타이틀 바 버튼 영역 클릭 시 드래그 무시 (DragMove가 마우스를 캡처하여 MouseLeftButtonUp 차단 방지)
var src = e.OriginalSource as DependencyObject;
while (src != null && src != sender)
{
if (src is Border b && b.Name is "BtnClose" or "BtnMinimize" or "BtnClear")
return;
src = VisualTreeHelper.GetParent(src);
}
if (e.ClickCount == 1) DragMove();
}
private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Hide();
private void BtnMinimize_Click(object sender, MouseButtonEventArgs e) => WindowState = WindowState.Minimized;
private void BtnClear_Click(object sender, MouseButtonEventArgs e) => Reset();
private void TitleBtn_MouseEnter(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
private void TitleBtn_MouseLeave(object sender, MouseEventArgs e)
{
if (sender is Border b)
b.Background = Brushes.Transparent;
}
private static string Truncate(string text, int maxLen)
=> text.Length <= maxLen ? text : text[..maxLen] + "…";
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_NCHITTEST)
{
var pt = PointFromScreen(new Point(
(short)(lParam.ToInt32() & 0xFFFF),
(short)((lParam.ToInt32() >> 16) & 0xFFFF)));
const double grip = 8;
var w = ActualWidth;
var h = ActualHeight;
if (pt.X < grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPLEFT; }
if (pt.X > w - grip && pt.Y < grip) { handled = true; return (IntPtr)HTTOPRIGHT; }
if (pt.X < grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMLEFT; }
if (pt.X > w - grip && pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOMRIGHT; }
if (pt.X < grip) { handled = true; return (IntPtr)HTLEFT; }
if (pt.X > w - grip) { handled = true; return (IntPtr)HTRIGHT; }
if (pt.Y < grip) { handled = true; return (IntPtr)HTTOP; }
if (pt.Y > h - grip) { handled = true; return (IntPtr)HTBOTTOM; }
}
return IntPtr.Zero;
}
}