Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.xaml.cs
lacvet 45779f8c6f
Some checks failed
Release Gate / gate (push) Has been cancelled
컨텍스트 압축 이력 재노출과 compact 비교 기반 보강
- AX Agent 하단 컨텍스트 카드 툴팁에 최근 압축 시각, 자동/수동 여부, 압축 전후 토큰, 절감량을 다시 볼 수 있는 compact 이력을 추가함
- 수동 /compact와 전송 전 자동 컨텍스트 압축이 모두 같은 compaction 통계 기록 경로를 사용하도록 정리해 결과를 이후 UI에서도 확인할 수 있게 보강함
- README와 docs/DEVELOPMENT.md에 2026-04-04 23:28 (KST) 기준 이력을 반영함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-04 23:29:45 +09:00

19467 lines
796 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using System.IO;
using Microsoft.Win32;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>AX Agent 창. 데스크톱 코파일럿 스타일 — 사이드바 + 카테고리 분류 + 타임라인.</summary>
public partial class ChatWindow : Window
{
private const string UnifiedAdminPassword = "axgo123!";
private readonly SettingsService _settings;
private readonly ChatStorageService _storage;
private readonly DraftQueueProcessorService _draftQueueProcessor = new();
private readonly LlmService _llm;
private readonly ToolRegistry _toolRegistry;
private readonly AgentLoopService _agentLoop;
private readonly ModelRouterService _router;
private AppStateService _appState => (System.Windows.Application.Current as App)?.AppState ?? new AppStateService();
private readonly object _convLock = new();
private readonly Dictionary<string, FrameworkElement> _runBannerAnchors = new(StringComparer.OrdinalIgnoreCase);
private ChatConversation? _currentConversation;
private string? _runningDraftId;
private CancellationTokenSource? _streamCts;
private bool _isStreaming;
private bool _sidebarVisible = true;
private string _selectedCategory = ""; // "" = 전체
private readonly Dictionary<string, string> _tabSelectedCategory = new(StringComparer.OrdinalIgnoreCase)
{
["Chat"] = "",
["Cowork"] = "",
["Code"] = "",
};
private readonly Dictionary<string, bool> _tabSidebarVisible = new(StringComparer.OrdinalIgnoreCase)
{
["Chat"] = true,
["Cowork"] = true,
["Code"] = true,
};
private bool _failedOnlyFilter;
private bool _runningOnlyFilter;
private int _failedConversationCount;
private int _runningConversationCount;
private int _spotlightConversationCount;
private bool _sortConversationsByRecent = false;
private bool _isInlineSettingsSyncing;
private string? _streamRunTab;
private bool _forceClose = false; // 앱 종료 시 진짜 닫기 플래그
// 스트리밍 UI — 커서 깜빡임 + 로딩 아이콘
private readonly DispatcherTimer _cursorTimer;
private bool _cursorVisible = true;
private TextBlock? _activeStreamText;
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
private bool _aiIconPulseStopped; // 펄스 1회만 중지
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
private PlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어
private bool _userScrolled; // 사용자가 위로 스크롤했는지
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
private string _folderMenuSearchText = "";
// 경과 시간 표시
private readonly DispatcherTimer _elapsedTimer;
private DateTime _streamStartTime;
private TextBlock? _elapsedLabel;
// 타이핑 효과
private readonly DispatcherTimer _typingTimer;
private readonly DispatcherTimer _gitRefreshTimer;
private CancellationTokenSource? _gitStatusRefreshCts;
private int _displayedLength; // 현재 화면에 표시된 글자 수
private ResourceDictionary? _agentThemeDictionary;
private bool _isOverlaySettingsSyncing;
private string? _currentGitBranchName;
private string? _currentGitTooltip;
private string? _currentGitRoot;
private int _currentGitChangedFileCount;
private int _currentGitInsertions;
private int _currentGitDeletions;
private List<string> _currentGitBranches = new();
private string? _currentGitUpstreamStatus;
private readonly List<string> _recentGitBranches = new();
private string _gitBranchSearchText = "";
private StackPanel? _selectedMessageActionBar;
private Border? _selectedMessageBorder;
private bool _isRefreshingFromSettings;
private int? _lastCompactionBeforeTokens;
private int? _lastCompactionAfterTokens;
private DateTime? _lastCompactionAt;
private bool _lastCompactionWasAutomatic;
private void ApplyQuickActionVisual(Button button, bool active, string activeBg, string activeFg)
{
if (button?.Content is not string text)
return;
button.Background = active ? BrushFromHex(activeBg) : Brushes.Transparent;
button.BorderBrush = active ? BrushFromHex(activeBg) : Brushes.Transparent;
button.BorderThickness = new Thickness(active ? 1 : 0);
button.Foreground = active ? BrushFromHex(activeFg) : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
button.Content = text;
}
private sealed class ConversationMeta
{
public string Id { get; init; } = "";
public string Title { get; init; } = "";
public string UpdatedAtText { get; init; } = "";
public bool Pinned { get; init; }
public string Category { get; init; } = ChatCategory.General;
public string Symbol { get; init; } = "\uE8BD";
public string ColorHex { get; init; } = "#6B7280";
public string Tab { get; init; } = "Chat";
public DateTime UpdatedAt { get; init; }
/// <summary>첫 사용자 메시지 요약 (검색용, 최대 100자).</summary>
public string Preview { get; init; } = "";
/// <summary>분기 원본 대화 ID. null이면 원본 대화.</summary>
public string? ParentId { get; init; }
public int AgentRunCount { get; init; }
public int FailedAgentRunCount { get; init; }
public string LastAgentRunSummary { get; init; } = "";
public DateTime? LastFailedAt { get; init; }
public DateTime? LastCompletedAt { get; init; }
public bool IsRunning { get; init; }
public string WorkFolder { get; init; } = "";
}
public ChatWindow(SettingsService settings)
{
InitializeComponent();
_settings = settings;
_settings.SettingsChanged += Settings_SettingsChanged;
_storage = new ChatStorageService();
_llm = new LlmService(settings);
_router = new ModelRouterService(settings);
_toolRegistry = ToolRegistry.CreateDefault();
_agentLoop = new AgentLoopService(_llm, _toolRegistry, settings)
{
Dispatcher = action =>
{
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
appDispatcher.Invoke(action);
},
AskPermissionCallback = async (toolName, filePath) =>
{
if (IsPermissionAutoApprovedForSession(toolName, filePath))
return true;
PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject;
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
await appDispatcher.InvokeAsync(() =>
{
AgentLoopService.PermissionPromptPreview? preview = null;
if (_agentLoop != null && _agentLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview))
preview = pendingPreview;
decision = PermissionRequestWindow.Show(this, toolName, filePath, preview);
});
if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession)
RememberPermissionRuleForSession(toolName, filePath);
return decision != PermissionRequestWindow.PermissionPromptResult.Reject;
},
UserAskCallback = async (question, options, defaultValue) =>
{
string? response = null;
var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher;
await appDispatcher.InvokeAsync(() =>
{
response = UserAskDialog.Show(question, options, defaultValue);
});
return response;
},
};
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "none";
_cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) };
_cursorTimer.Tick += CursorTimer_Tick;
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_elapsedTimer.Tick += ElapsedTimer_Tick;
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(12) };
_typingTimer.Tick += TypingTimer_Tick;
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
_gitRefreshTimer.Tick += async (_, _) =>
{
_gitRefreshTimer.Stop();
await RefreshGitBranchStatusAsync();
};
KeyDown += ChatWindow_KeyDown;
UpdateConversationFailureFilterUi();
UpdateConversationSortUi();
UpdateConversationRunningFilterUi();
Loaded += (_, _) =>
{
ApplyAgentThemeResources();
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
SetupUserInfo();
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = _settings.Settings.Llm.FolderDataUsage ?? "none";
UpdateAnalyzerButtonVisibility();
UpdateModelLabel();
RefreshInlineSettingsPanel();
ApplyExpressionLevelUi();
UpdateSidebarModeMenu();
RefreshContextUsageVisual();
InputBox.Focus();
MessageScroll.ScrollChanged += MessageScroll_ScrollChanged;
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
Dispatcher.BeginInvoke(() =>
{
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildTopicButtons();
RestoreLastConversations();
RefreshConversationList();
UpdateTaskSummaryIndicators();
ScheduleGitBranchRefresh();
// 데이터 정리 (디스크 I/O)
_ = Task.Run(() =>
{
var retention = _settings.Settings.Llm.RetentionDays;
if (retention > 0) _storage.PurgeExpired(retention);
_storage.PurgeForDiskSpace();
});
}, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
// 입력 바 포커스 글로우 효과
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
InputBox.GotFocus += (_, _) => InputBorder.BorderBrush = accentBrush;
InputBox.LostFocus += (_, _) => InputBorder.BorderBrush = borderBrush;
// 드래그 앤 드롭 파일 첨부 + AI 액션 팝업
InputBorder.AllowDrop = true;
InputBorder.DragOver += (_, de) =>
{
de.Effects = de.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
de.Handled = true;
};
InputBorder.Drop += (_, de) =>
{
if (de.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
{
var enableAi = _settings.Settings.Llm.EnableDragDropAiActions;
if (enableAi && files.Length <= 5)
ShowDropActionMenu(files);
else
foreach (var f in files) AddAttachedFile(f);
}
};
// 스킬 시스템 초기화
if (_settings.Settings.Llm.EnableSkillSystem)
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(_settings.Settings.Llm.SkillsFolderPath);
UpdateConditionalSkillActivation(reset: true);
}
// 슬래시 명령어 칩 닫기 (× 버튼)
SlashChipClose.MouseLeftButtonUp += (_, _) =>
{
HideSlashChip(restoreText: true);
InputBox.Focus();
};
// InputBox에서 슬래시 팝업 열린 상태로 마우스 휠 → 팝업 스크롤
InputBox.PreviewMouseWheel += (_, me) =>
{
if (!SlashPopup.IsOpen) return;
me.Handled = true;
SlashPopup_ScrollByDelta(me.Delta);
};
// 탭 UI 초기 상태
UpdateFolderBar();
// 호버 애니메이션 — 독립 공간이 있는 버튼에만 Scale 적용
// (GhostBtn 스타일 버튼은 XAML에서 배경색+opacity 호버 처리)
ApplyHoverBounceAnimation(BtnModelSelector);
ApplyHoverBounceAnimation(BtnTemplateSelector, -1.5);
ApplyHoverScaleAnimation(BtnSend, 1.12);
ApplyHoverScaleAnimation(BtnStop, 1.12);
};
Closed += (_, _) =>
{
_settings.SettingsChanged -= Settings_SettingsChanged;
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
_streamCts?.Cancel();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_llm.Dispose();
};
}
private void Settings_SettingsChanged(object? sender, EventArgs e)
{
if (_forceClose || !IsLoaded || _isRefreshingFromSettings)
return;
Dispatcher.BeginInvoke(new Action(() =>
{
if (_forceClose || !IsLoaded)
return;
_isRefreshingFromSettings = true;
try
{
RefreshFromSavedSettings();
}
finally
{
_isRefreshingFromSettings = false;
}
}), DispatcherPriority.Input);
}
private bool IsPermissionAutoApprovedForSession(string toolName, string target)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
return false;
var normalizedTarget = target.Trim();
var pathLikeTool = IsPathLikePermissionTool(toolName);
foreach (var rule in _sessionPermissionRules)
{
var pivot = rule.IndexOf('|');
if (pivot <= 0 || pivot >= rule.Length - 1)
continue;
var ruleTool = rule[..pivot];
var ruleTarget = rule[(pivot + 1)..];
if (!string.Equals(ruleTool, toolName, StringComparison.OrdinalIgnoreCase))
continue;
if (pathLikeTool)
{
if (normalizedTarget.StartsWith(ruleTarget, StringComparison.OrdinalIgnoreCase)
|| string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase))
return true;
}
else if (string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private void RememberPermissionRuleForSession(string toolName, string target)
{
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
return;
var normalizedTarget = target.Trim();
var scopedTarget = normalizedTarget;
if (IsPathLikePermissionTool(toolName))
{
try
{
if (System.IO.Path.IsPathRooted(normalizedTarget))
{
var full = System.IO.Path.GetFullPath(normalizedTarget);
var directory = System.IO.Path.GetDirectoryName(full);
if (!string.IsNullOrWhiteSpace(directory))
scopedTarget = directory.TrimEnd('\\', '/') + System.IO.Path.DirectorySeparatorChar;
}
}
catch
{
// Ignore invalid path and fall back to exact target matching.
}
}
_sessionPermissionRules.Add($"{toolName}|{scopedTarget}");
}
private static bool IsPathLikePermissionTool(string toolName)
{
var normalized = toolName.Trim().ToLowerInvariant();
return normalized.Contains("file", StringComparison.Ordinal)
|| normalized.Contains("edit", StringComparison.Ordinal)
|| normalized.Contains("write", StringComparison.Ordinal);
}
/// <summary>
/// X 버튼으로 닫을 때 창을 숨기기만 합니다 (재사용으로 다음 번 빠르게 열림).
/// 앱 종료 시에는 ForceClose()를 사용합니다.
/// </summary>
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (!_forceClose)
{
e.Cancel = true;
Hide();
return;
}
base.OnClosing(e);
}
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
public void ForceClose()
{
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
lock (_convLock)
{
ChatSession?.SaveCurrentConversation(_storage, _activeTab);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
_forceClose = true;
Close();
}
// ─── 사용자 정보 ────────────────────────────────────────────────────
private void SetupUserInfo()
{
var userName = Environment.UserName;
// AD\, AD/, AD: 접두사 제거
var cleanName = userName;
foreach (var sep in new[] { '\\', '/', ':' })
{
var idx = cleanName.LastIndexOf(sep);
if (idx >= 0) cleanName = cleanName[(idx + 1)..];
}
var initial = cleanName.Length > 0 ? cleanName[..1].ToUpper() : "U";
var pcName = Environment.MachineName;
UserInitialSidebar.Text = initial;
UserInitialIconBar.Text = initial;
UserNameText.Text = cleanName;
UserPcText.Text = pcName;
BtnUserIconBar.ToolTip = $"{cleanName} ({pcName})";
}
// ─── 스크롤 동작 ──────────────────────────────────────────────────
private void MessageScroll_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
// 스크롤 가능 영역이 없으면(콘텐츠가 짧음) 항상 바닥
if (MessageScroll.ScrollableHeight <= 1)
{
_userScrolled = false;
return;
}
// 콘텐츠 크기 변경(ExtentHeightChange > 0)에 의한 스크롤은 무시 — 사용자 조작만 감지
if (Math.Abs(e.ExtentHeightChange) > 0.5)
return;
var atBottom = MessageScroll.VerticalOffset >= MessageScroll.ScrollableHeight - 40;
_userScrolled = !atBottom;
}
private void AutoScrollIfNeeded()
{
if (!_userScrolled)
SmoothScrollToEnd();
}
/// <summary>새 응답 시작 시 강제로 하단 스크롤합니다 (사용자 스크롤 상태 리셋).</summary>
private void ForceScrollToEnd()
{
_userScrolled = false;
Dispatcher.InvokeAsync(() => SmoothScrollToEnd(), DispatcherPriority.Background);
}
/// <summary>부드러운 자동 스크롤 — 하단으로 부드럽게 이동합니다.</summary>
private void SmoothScrollToEnd()
{
var targetOffset = MessageScroll.ScrollableHeight;
var currentOffset = MessageScroll.VerticalOffset;
var diff = targetOffset - currentOffset;
// 차이가 작으면 즉시 이동 (깜빡임 방지)
if (diff <= 60)
{
MessageScroll.ScrollToEnd();
return;
}
// 부드럽게 스크롤 (DoubleAnimation)
var animation = new DoubleAnimation
{
From = currentOffset,
To = targetOffset,
Duration = TimeSpan.FromMilliseconds(200),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
};
animation.Completed += (_, _) => MessageScroll.ScrollToVerticalOffset(targetOffset);
// ScrollViewer에 직접 애니메이션을 적용할 수 없으므로 타이머 기반으로 보간
var startTime = DateTime.UtcNow;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60fps
EventHandler tickHandler = null!;
tickHandler = (_, _) =>
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
var progress = Math.Min(elapsed / 200.0, 1.0);
var eased = 1.0 - Math.Pow(1.0 - progress, 3);
var offset = currentOffset + diff * eased;
MessageScroll.ScrollToVerticalOffset(offset);
if (progress >= 1.0)
{
timer.Stop();
timer.Tick -= tickHandler;
}
};
timer.Tick += tickHandler;
timer.Start();
}
// ─── 대화 제목 인라인 편집 ──────────────────────────────────────────
private void ChatTitle_MouseDown(object sender, MouseButtonEventArgs e)
{
lock (_convLock)
{
if (_currentConversation == null) return;
}
ChatTitle.Visibility = Visibility.Collapsed;
ChatTitleEdit.Text = ChatTitle.Text;
ChatTitleEdit.Visibility = Visibility.Visible;
ChatTitleEdit.Focus();
ChatTitleEdit.SelectAll();
}
private void ChatTitleEdit_LostFocus(object sender, RoutedEventArgs e) => CommitTitleEdit();
private void ChatTitleEdit_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) { CommitTitleEdit(); e.Handled = true; }
if (e.Key == Key.Escape) { CancelTitleEdit(); e.Handled = true; }
}
private void CommitTitleEdit()
{
var newTitle = ChatTitleEdit.Text.Trim();
ChatTitleEdit.Visibility = Visibility.Collapsed;
ChatTitle.Visibility = Visibility.Visible;
if (string.IsNullOrEmpty(newTitle)) return;
lock (_convLock)
{
if (_currentConversation == null) return;
var session = ChatSession;
if (session != null)
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
else
_currentConversation.Title = newTitle;
}
ChatTitle.Text = newTitle;
try { if (ChatSession == null) { ChatConversation conv; lock (_convLock) conv = _currentConversation!; _storage.Save(conv); } } catch { }
RefreshConversationList();
}
private void CancelTitleEdit()
{
ChatTitleEdit.Visibility = Visibility.Collapsed;
ChatTitle.Visibility = Visibility.Visible;
}
// ─── 카테고리 드롭다운 ──────────────────────────────────────────────
private void BtnCategoryDrop_Click(object sender, RoutedEventArgs e)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var popup = new Popup
{
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
PlacementTarget = BtnCategoryDrop,
Placement = PlacementMode.Bottom,
HorizontalOffset = 0,
VerticalOffset = 4,
};
var container = new Border
{
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
MinWidth = 180,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
},
};
var stack = new StackPanel();
Border CreateCatItem(string icon, string text, Brush iconColor, bool isSelected, Action onClick)
{
var item = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 1, 0, 1),
Cursor = Cursors.Hand,
};
var g = new Grid();
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
var iconTb = new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(iconTb, 0);
g.Children.Add(iconTb);
var textTb = new TextBlock
{
Text = text, FontSize = 12.5, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(textTb, 1);
g.Children.Add(textTb);
if (isSelected)
{
var check = CreateSimpleCheck(accentBrush, 14);
Grid.SetColumn(check, 2);
g.Children.Add(check);
}
item.Child = g;
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 += (_, _) => { popup.IsOpen = false; onClick(); };
return item;
}
Border CreateSep() => new()
{
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
};
// 전체 보기
var allLabel = _activeTab switch
{
"Cowork" or "Code" => "모든 프로젝트",
_ => "모든 주제",
};
stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText,
string.IsNullOrEmpty(_selectedCategory),
() => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); }));
stack.Children.Add(CreateSep());
if (_activeTab == "Cowork" || _activeTab == "Code")
{
// 코워크/코드: 워크스페이스 기반 필터
var workspaces = _storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
.Select(c => c.WorkFolder?.Trim() ?? "")
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => System.IO.Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)
.ThenBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var workspace in workspaces)
{
var displayName = System.IO.Path.GetFileName(workspace.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(displayName))
displayName = workspace;
var capturedWorkspace = workspace;
var item = CreateCatItem("\uE8B7", displayName, accentBrush,
string.Equals(_selectedCategory, capturedWorkspace, StringComparison.OrdinalIgnoreCase),
() => { _selectedCategory = capturedWorkspace; UpdateCategoryLabel(); RefreshConversationList(); });
item.ToolTip = workspace;
stack.Children.Add(item);
}
}
else
{
// Chat: 기존 ChatCategory 기반
foreach (var (key, label, symbol, color) in ChatCategory.All)
{
var capturedKey = key;
stack.Children.Add(CreateCatItem(symbol, label, BrushFromHex(color),
_selectedCategory == capturedKey,
() => { _selectedCategory = capturedKey; UpdateCategoryLabel(); RefreshConversationList(); }));
}
// 커스텀 프리셋 통합 필터 (Chat)
var chatCustom = _settings.Settings.Llm.CustomPresets.Where(c => c.Tab == "Chat").ToList();
if (chatCustom.Count > 0)
{
stack.Children.Add(CreateSep());
stack.Children.Add(CreateCatItem("\uE710", "커스텀 프리셋", secondaryText,
_selectedCategory == "__custom__",
() => { _selectedCategory = "__custom__"; UpdateCategoryLabel(); RefreshConversationList(); }));
}
}
container.Child = stack;
popup.Child = container;
popup.IsOpen = true;
}
private void UpdateCategoryLabel()
{
if (string.IsNullOrEmpty(_selectedCategory))
{
CategoryLabel.Text = _activeTab switch { "Cowork" or "Code" => "모든 프로젝트", _ => "주제 선택" };
CategoryIcon.Text = "\uE8BD";
}
else if (_activeTab == "Cowork" || _activeTab == "Code")
{
var displayName = System.IO.Path.GetFileName(_selectedCategory.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar));
CategoryLabel.Text = string.IsNullOrWhiteSpace(displayName) ? _selectedCategory : displayName;
CategoryIcon.Text = "\uE8B7";
}
else if (_selectedCategory == "__custom__")
{
CategoryLabel.Text = "커스텀 프리셋";
CategoryIcon.Text = "\uE710";
}
else
{
// ChatCategory에서 찾기
foreach (var (key, label, symbol, _) in ChatCategory.All)
{
if (key == _selectedCategory)
{
CategoryLabel.Text = label;
CategoryIcon.Text = symbol;
return;
}
}
// 프리셋 카테고리에서 찾기 (Cowork/Code)
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
var match = presets.FirstOrDefault(p => p.Category == _selectedCategory);
if (match != null)
{
CategoryLabel.Text = match.Label;
CategoryIcon.Text = match.Symbol;
}
else
{
CategoryLabel.Text = _selectedCategory;
CategoryIcon.Text = "\uE8BD";
}
}
}
private void UpdateSidebarModeMenu()
{
if (SidebarChatMenu == null || SidebarCoworkMenu == null || SidebarCodeMenu == null)
return;
var chatVisible = _activeTab == "Chat" ? Visibility.Visible : Visibility.Collapsed;
var coworkVisible = _activeTab == "Cowork" ? Visibility.Visible : Visibility.Collapsed;
var codeVisible = _activeTab == "Code" ? Visibility.Visible : Visibility.Collapsed;
if (SidebarChatMenu.Visibility != chatVisible) SidebarChatMenu.Visibility = chatVisible;
if (SidebarCoworkMenu.Visibility != coworkVisible) SidebarCoworkMenu.Visibility = coworkVisible;
if (SidebarCodeMenu.Visibility != codeVisible) SidebarCodeMenu.Visibility = codeVisible;
if (SidebarModeBadgeTitle != null && SidebarModeBadgeIcon != null)
{
if (_activeTab == "Cowork")
{
SidebarModeBadgeTitle.Text = "Cowork 메뉴";
SidebarModeBadgeIcon.Text = "\uE8FD";
}
else if (_activeTab == "Code")
{
SidebarModeBadgeTitle.Text = "Code 메뉴";
SidebarModeBadgeIcon.Text = "\uE943";
}
else
{
SidebarModeBadgeTitle.Text = "Chat 메뉴";
SidebarModeBadgeIcon.Text = "\uE8BD";
}
}
if (SidebarChatRunningState != null)
SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF";
}
private void SidebarChatAll_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_selectedCategory = "";
UpdateCategoryLabel();
RefreshConversationList();
BuildTopicButtons();
if (EmptyState != null)
EmptyState.Visibility = Visibility.Visible;
if (TopicButtonPanel != null)
TopicButtonPanel.Visibility = Visibility.Visible;
}
private void SidebarChatRunning_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnRunningOnlyFilter_Click(this, new RoutedEventArgs());
private void SidebarCoworkCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnCategoryDrop_Click(this, new RoutedEventArgs());
private void SidebarCoworkPreset_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
BuildTopicButtons();
ShowToast("프리셋 카드가 갱신되었습니다.");
}
private void SidebarCoworkExecution_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnToggleExecutionLog_Click(this, new RoutedEventArgs());
private void SidebarCodeCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> BtnCategoryDrop_Click(this, new RoutedEventArgs());
private void SidebarCodeLanguage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
BuildCodeBottomBar();
ShowToast("코드 옵션 메뉴를 갱신했습니다.");
}
private void SidebarCodeFiles_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (FileBrowserPanel == null)
return;
var visible = FileBrowserPanel.Visibility == Visibility.Visible;
FileBrowserPanel.Visibility = visible ? Visibility.Collapsed : Visibility.Visible;
if (!visible)
BuildFileTree();
}
// ─── 창 컨트롤 ──────────────────────────────────────────────────────
// WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var source = System.Windows.Interop.HwndSource.FromHwnd(
new System.Windows.Interop.WindowInteropHelper(this).Handle);
source?.AddHook(WndProc);
}
private void ApplyAgentThemeResources()
{
var themeUri = BuildAgentThemeDictionaryUri();
try
{
if (_agentThemeDictionary != null)
Resources.MergedDictionaries.Remove(_agentThemeDictionary);
_agentThemeDictionary = new ResourceDictionary
{
Source = themeUri,
};
Resources.MergedDictionaries.Insert(0, _agentThemeDictionary);
}
catch
{
// 테마 로드 실패 시 기본 리소스 유지
}
}
private Uri BuildAgentThemeDictionaryUri()
{
var mode = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant();
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claw").Trim().ToLowerInvariant() switch
{
"codex" => "Codex",
"slate" => "Slate",
_ => "Claw",
};
var effectiveMode = mode switch
{
"light" => "Light",
"dark" => "Dark",
_ => "System",
};
var candidate = $"pack://application:,,,/Themes/Agent{preset}{effectiveMode}.xaml";
return new Uri(candidate);
}
private static bool IsSystemDarkTheme()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
return key?.GetValue("AppsUseLightTheme") is int v && v == 0;
}
catch
{
return true;
}
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보
if (msg == 0x0024)
{
var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
var workArea = screen.WorkingArea;
var monitor = screen.Bounds;
var source = System.Windows.Interop.HwndSource.FromHwnd(hwnd);
var dpiScale = source?.CompositionTarget?.TransformToDevice.M11 ?? 1.0;
// MINMAXINFO: ptReserved(0,4) ptMaxSize(8,12) ptMaxPosition(16,20) ptMinTrackSize(24,28) ptMaxTrackSize(32,36)
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 8, workArea.Width); // ptMaxSize.cx
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 12, workArea.Height); // ptMaxSize.cy
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 16, workArea.Left - monitor.Left); // ptMaxPosition.x
System.Runtime.InteropServices.Marshal.WriteInt32(lParam, 20, workArea.Top - monitor.Top); // ptMaxPosition.y
handled = true;
}
return IntPtr.Zero;
}
private void BtnMinimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void BtnMaximize_Click(object sender, RoutedEventArgs e)
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
MaximizeIcon.Text = WindowState == WindowState.Maximized ? "\uE923" : "\uE739"; // 복원/최대화 아이콘
}
private void BtnClose_Click(object sender, RoutedEventArgs e) => Close();
// ─── 탭 전환 ──────────────────────────────────────────────────────────
private string _activeTab = "Chat";
private ChatSessionStateService? ChatSession => _appState.ChatSession;
private void SaveCurrentTabConversationId()
{
lock (_convLock)
{
ChatSession?.SaveCurrentConversation(_storage, _activeTab);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
SyncTabConversationIdsFromSession();
}
// 탭별 마지막 대화 ID를 설정에 영속 저장 (앱 재시작 시 복원용)
SaveLastConversations();
}
/// <summary>탭 전환 전 스트리밍 중이면 즉시 중단합니다.</summary>
private void StopStreamingIfActive()
{
if (!_isStreaming) return;
// 스트리밍 중단
_streamCts?.Cancel();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
StopRainbowGlow();
HideStickyProgress();
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_isStreaming = false;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnPause.Visibility = Visibility.Collapsed;
PauseIcon.Text = "\uE769"; // 리셋
BtnSend.Visibility = Visibility.Visible;
_streamCts?.Dispose();
_streamCts = null;
SetStatusIdle();
}
private void TabChat_Checked(object sender, RoutedEventArgs e)
{
if (_activeTab == "Chat") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = "Chat";
RestorePerTabUiState();
UpdateTabUI();
}
private void TabCowork_Checked(object sender, RoutedEventArgs e)
{
if (_activeTab == "Cowork") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = "Cowork";
RestorePerTabUiState();
UpdateTabUI();
}
private void TabCode_Checked(object sender, RoutedEventArgs e)
{
if (_activeTab == "Code") return;
StopStreamingIfActive();
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = "Code";
RestorePerTabUiState();
UpdateTabUI();
}
/// <summary>탭별로 마지막으로 활성화된 대화 ID를 기억.</summary>
private readonly Dictionary<string, string?> _tabConversationId = new()
{
["Chat"] = null, ["Cowork"] = null, ["Code"] = null,
};
private void SyncTabConversationIdsFromSession()
{
var session = ChatSession;
if (session == null)
return;
foreach (var key in _tabConversationId.Keys.ToList())
_tabConversationId[key] = session.GetConversationId(key);
}
private void SyncTabConversationIdsToSession()
{
var session = ChatSession;
if (session == null)
return;
foreach (var kv in _tabConversationId)
session.RememberConversation(kv.Key, kv.Value);
}
private void UpdateTabUI()
{
ApplyAgentThemeResources();
ApplyExpressionLevelUi();
ApplySidebarStateForActiveTab(animated: false);
if (CurrentTabTitle != null)
{
CurrentTabTitle.Text = _activeTab switch
{
"Cowork" => "AX Agent · Cowork",
"Code" => "AX Agent · Code",
_ => "AX Agent · Chat",
};
}
// 폴더 바는 Cowork/Code 탭에서만 표시
if (FolderBar != null)
FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed;
// 탭별 입력 안내 문구
if (InputWatermark != null)
{
InputWatermark.Text = _activeTab switch
{
"Cowork" => "에이전트에게 작업을 요청하세요 (파일 읽기/쓰기, 문서 생성...)",
"Code" => "코드 관련 작업을 요청하세요...",
_ => _promptCardPlaceholder,
};
}
// 권한 기본값 적용 (Cowork/Code 탭은 설정의 기본값 사용)
ApplyTabDefaultPermission();
// 포맷/디자인 드롭다운은 Cowork 탭에서만 표시
if (_activeTab == "Cowork")
{
BuildBottomBar();
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
}
else if (_activeTab == "Code")
{
// Code 탭: 언어 선택기
BuildCodeBottomBar();
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
}
else
{
MoodIconPanel.Children.Clear();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed;
}
// 탭별 프리셋 버튼 재구성
BuildTopicButtons();
UpdateSidebarModeMenu();
// 현재 대화를 해당 탭 대화로 전환
SwitchToTabConversation();
// Cowork/Code 탭 전환 시 팁 표시
ShowRandomTip();
}
private void PersistPerTabUiState()
{
_tabSelectedCategory[_activeTab] = _selectedCategory;
_tabSidebarVisible[_activeTab] = _sidebarVisible;
}
private void RestorePerTabUiState()
{
if (_tabSelectedCategory.TryGetValue(_activeTab, out var category))
_selectedCategory = category ?? "";
else
_selectedCategory = "";
UpdateCategoryLabel();
if (_tabSidebarVisible.TryGetValue(_activeTab, out var visible))
_sidebarVisible = visible;
else
_sidebarVisible = true;
}
private string GetAgentUiExpressionLevel()
{
var raw = _settings.Settings.Llm.AgentUiExpressionLevel;
return (raw ?? "balanced").Trim().ToLowerInvariant() switch
{
"rich" => "rich",
"simple" => "simple",
_ => "balanced",
};
}
private void ApplyExpressionLevelUi()
{
var level = GetAgentUiExpressionLevel();
if (InputBox != null)
{
InputBox.FontSize = level == "simple" ? 13 : 14;
InputBox.MaxHeight = level switch
{
"rich" => 220,
"simple" => 120,
_ => 160,
};
}
if (InputWatermark != null)
{
InputWatermark.FontSize = level == "simple" ? 13 : 14;
InputWatermark.Opacity = level == "rich" ? 0.8 : 0.7;
}
if (InlineSettingsHintText != null)
InlineSettingsHintText.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible;
if (InlineSettingsQuickActions != null)
InlineSettingsQuickActions.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible;
if (BtnTemplateSelector != null)
{
BtnTemplateSelector.Padding = level == "simple"
? new Thickness(8, 4, 8, 4)
: new Thickness(9, 4, 9, 4);
}
if (_failedOnlyFilter)
{
_failedOnlyFilter = false;
RefreshConversationList();
}
}
private void SwitchToTabConversation()
{
var session = ChatSession;
if (session != null)
{
var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
lock (_convLock) _currentConversation = conv;
SyncTabConversationIdsFromSession();
SaveLastConversations();
MessagePanel.Children.Clear();
RenderMessages();
EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible;
UpdateChatTitle();
RefreshConversationList();
UpdateFolderBar();
return;
}
// 기억된 대화가 없으면 새 대화
lock (_convLock)
{
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
?? new ChatConversation { Tab = _activeTab };
}
MessagePanel.Children.Clear();
EmptyState.Visibility = Visibility.Visible;
_attachedFiles.Clear();
RefreshAttachedFilesUI();
UpdateChatTitle();
RefreshConversationList();
UpdateFolderBar();
UpdateConditionalSkillActivation(reset: true);
}
// ─── 작업 폴더 ─────────────────────────────────────────────────────────
private readonly List<string> _attachedFiles = new();
private readonly List<ImageAttachment> _pendingImages = new();
private void FolderPathLabel_Click(object sender, MouseButtonEventArgs e) => ShowFolderMenu();
private void FolderMenuSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
_folderMenuSearchText = FolderMenuSearchBox?.Text?.Trim() ?? "";
RenderFolderMenuItems(_folderMenuSearchText);
}
private void ShowFolderMenu()
{
_folderMenuSearchText = FolderMenuSearchBox?.Text?.Trim() ?? "";
RenderFolderMenuItems(_folderMenuSearchText);
FolderMenuPopup.IsOpen = true;
}
private void RenderFolderMenuItems(string? searchText)
{
FolderMenuItems.Children.Clear();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var query = (searchText ?? "").Trim();
var maxDisplay = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
var currentFolder = GetCurrentWorkFolder();
var conversationFolders = _storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
.Select(c => c.WorkFolder?.Trim() ?? "")
.Where(p => IsPathAllowed(p) && Directory.Exists(p));
var recentFolders = _settings.Settings.Llm.RecentWorkFolders
.Where(p => IsPathAllowed(p) && System.IO.Directory.Exists(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var workspaceFolders = recentFolders
.Concat(conversationFolders)
.Concat(string.IsNullOrWhiteSpace(currentFolder) ? Enumerable.Empty<string>() : new[] { currentFolder })
.Distinct(StringComparer.OrdinalIgnoreCase)
.Where(path =>
string.IsNullOrWhiteSpace(query)
|| path.Contains(query, StringComparison.OrdinalIgnoreCase)
|| System.IO.Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase))
.Take(maxDisplay * 3)
.ToList();
var filteredRecent = recentFolders
.Where(path =>
string.IsNullOrWhiteSpace(query)
|| path.Contains(query, StringComparison.OrdinalIgnoreCase)
|| System.IO.Path.GetFileName(path).Contains(query, StringComparison.OrdinalIgnoreCase))
.Take(maxDisplay)
.ToList();
void AddWorkspaceRows(IEnumerable<string> folders)
{
foreach (var folder in folders)
{
var isActive = folder.Equals(currentFolder, StringComparison.OrdinalIgnoreCase);
var displayName = System.IO.Path.GetFileName(folder);
if (string.IsNullOrEmpty(displayName)) displayName = folder;
var detailText = isActive ? $"현재 선택 · {folder}" : folder;
var itemBorder = CreatePopupMenuRow(
isActive ? "\uE73E" : "\uE8B7",
displayName,
detailText,
isActive,
accentBrush,
secondaryText,
primaryText,
() =>
{
FolderMenuPopup.IsOpen = false;
SetWorkFolder(folder);
});
itemBorder.ToolTip = folder;
var capturedPath = folder;
// 우클릭 → 컨텍스트 메뉴 (삭제, 폴더 열기)
itemBorder.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowRecentFolderContextMenu(capturedPath);
};
FolderMenuItems.Children.Add(itemBorder);
}
}
if (filteredRecent.Count > 0)
{
FolderMenuItems.Children.Add(CreatePopupSectionLabel($"최근 워크스페이스 · {filteredRecent.Count}", new Thickness(10, 6, 10, 4)));
AddWorkspaceRows(filteredRecent);
}
var remainingFolders = workspaceFolders
.Where(path => !filteredRecent.Contains(path, StringComparer.OrdinalIgnoreCase))
.ToList();
if (remainingFolders.Count > 0)
{
var workspaceLabel = filteredRecent.Count > 0
? $"전체 워크스페이스 · {remainingFolders.Count}"
: $"워크스페이스 · {remainingFolders.Count}";
FolderMenuItems.Children.Add(CreatePopupSectionLabel(workspaceLabel, new Thickness(10, 6, 10, 4)));
AddWorkspaceRows(remainingFolders);
FolderMenuItems.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(8, 4, 8, 4),
Opacity = 0.5,
});
}
else if (filteredRecent.Count == 0)
{
FolderMenuItems.Children.Add(new TextBlock
{
Text = "검색 결과가 없습니다.",
FontSize = 11.5,
Foreground = secondaryText,
Margin = new Thickness(10, 8, 10, 10),
});
}
// 폴더 찾아보기 버튼
var browseSp = new StackPanel { Orientation = Orientation.Horizontal };
browseSp.Children.Add(new TextBlock
{
Text = "\uED25",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 14,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
browseSp.Children.Add(new TextBlock
{
Text = "폴더 찾아보기...",
FontSize = 14,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var browseBorder = new Border
{
Child = browseSp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(10, 7, 10, 7),
};
browseBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); };
browseBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
browseBorder.MouseLeftButtonUp += (_, _) =>
{
FolderMenuPopup.IsOpen = false;
BrowseWorkFolder();
};
FolderMenuItems.Children.Add(browseBorder);
}
private void BrowseWorkFolder()
{
var dlg = new System.Windows.Forms.FolderBrowserDialog
{
Description = "작업 폴더를 선택하세요",
ShowNewFolderButton = false,
UseDescriptionForTitle = true,
};
var currentFolder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(currentFolder) && System.IO.Directory.Exists(currentFolder))
dlg.SelectedPath = currentFolder;
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
if (!IsPathAllowed(dlg.SelectedPath))
{
CustomMessageBox.Show("이 경로는 작업 폴더로 선택할 수 없습니다.", "경로 제한", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
SetWorkFolder(dlg.SelectedPath);
}
/// <summary>경로 유효성 검사 — 차단 대상 경로 필터링.</summary>
private static bool IsPathAllowed(string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
// C:\ 루트 차단
var normalized = path.TrimEnd('\\', '/');
if (normalized.Equals("C:", StringComparison.OrdinalIgnoreCase)) return false;
// "Document" 포함 경로 차단 (대소문자 무시)
if (path.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0) return false;
return true;
}
private void SetWorkFolder(string path)
{
// 루트 드라이브 전체를 작업공간으로 설정하는 것을 차단
// 예: "C:\", "D:\", "E:\" 등
var fullPath = System.IO.Path.GetFullPath(path);
var root = System.IO.Path.GetPathRoot(fullPath);
if (!string.IsNullOrEmpty(root) && fullPath.TrimEnd('\\', '/').Equals(root.TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase))
{
ShowToast($"드라이브 루트({root})는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.", "\uE783", 3000);
return;
}
FolderPathLabel.Text = path;
FolderPathLabel.ToolTip = path;
lock (_convLock)
{
if (_currentConversation != null)
{
var session = ChatSession;
if (session != null)
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = path, _storage);
else
_currentConversation.WorkFolder = path;
}
}
// 최근 폴더 목록에 추가 (차단 경로 제외)
var recent = _settings.Settings.Llm.RecentWorkFolders;
recent.RemoveAll(p => !IsPathAllowed(p));
recent.Remove(path);
recent.Insert(0, path);
var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentFolders, 3, 30);
if (recent.Count > maxRecent) recent.RemoveRange(maxRecent, recent.Count - maxRecent);
_settings.Settings.Llm.WorkFolder = path;
_settings.Save();
RefreshContextUsageVisual();
ScheduleGitBranchRefresh();
UpdateConditionalSkillActivation(reset: true);
}
private string GetCurrentWorkFolder()
{
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.WorkFolder))
return _currentConversation.WorkFolder;
}
return _settings.Settings.Llm.WorkFolder;
}
/// <summary>
/// 현재 작업 컨텍스트(첨부 파일 + 작업 폴더) 기준으로
/// 조건부 paths 스킬 활성화를 갱신합니다.
/// </summary>
private void UpdateConditionalSkillActivation(bool reset = false)
{
if (!_settings.Settings.Llm.EnableSkillSystem) return;
var cwd = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(cwd) || !System.IO.Directory.Exists(cwd)) return;
if (reset) SkillService.ResetConditionalSkillActivation();
SkillService.ActivateConditionalSkillsForPaths(_attachedFiles, cwd);
}
private Popup? _sharedContextPopup;
private (Popup Popup, StackPanel Panel) CreateThemedPopupMenu(
UIElement? placementTarget = null,
PlacementMode placement = PlacementMode.MousePoint,
double minWidth = 200)
{
_sharedContextPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
var bg = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var panel = new StackPanel { Margin = new Thickness(2) };
var container = new Border
{
Background = bg,
BorderBrush = border,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(6),
MinWidth = minWidth,
Child = panel,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16,
ShadowDepth = 3,
Opacity = 0.18,
Color = Colors.Black,
Direction = 270,
},
};
var popup = new Popup
{
Child = container,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Placement = placement,
PlacementTarget = placementTarget,
};
_sharedContextPopup = popup;
return (popup, panel);
}
private Border CreatePopupMenuItem(
Popup popup,
string icon,
string label,
Brush iconBrush,
Brush labelBrush,
Brush hoverBrush,
Action action)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12.5,
Foreground = iconBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 9, 0),
});
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 12.5,
Foreground = labelBrush,
VerticalAlignment = VerticalAlignment.Center,
});
var item = new Border
{
Child = sp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(10, 7, 12, 7),
Margin = new Thickness(0, 1, 0, 1),
};
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBrush; };
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
item.MouseLeftButtonUp += (_, _) =>
{
popup.SetCurrentValue(Popup.IsOpenProperty, false);
action();
};
return item;
}
private static void AddPopupMenuSeparator(Panel panel, Brush brush)
{
panel.Children.Add(new Border
{
Height = 1,
Margin = new Thickness(10, 4, 10, 4),
Background = brush,
Opacity = 0.35,
});
}
/// <summary>최근 폴더 항목 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
private void ShowRecentFolderContextMenu(string folderPath)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var warningBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
var (popup, panel) = CreateThemedPopupMenu();
panel.Children.Add(CreatePopupMenuItem(popup, "\uED25", "폴더 열기", secondaryText, primaryText, hoverBg, () =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = folderPath,
UseShellExecute = true,
});
}
catch { }
}));
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "경로 복사", secondaryText, primaryText, hoverBg, () =>
{
try { Clipboard.SetText(folderPath); } catch { }
}));
AddPopupMenuSeparator(panel, borderBrush);
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "목록에서 삭제", warningBrush, warningBrush, hoverBg, () =>
{
_settings.Settings.Llm.RecentWorkFolders.RemoveAll(
p => p.Equals(folderPath, StringComparison.OrdinalIgnoreCase));
_settings.Save();
// 메뉴 새로고침
if (FolderMenuPopup.IsOpen)
ShowFolderMenu();
}));
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
}
private void BtnFolderClear_Click(object sender, RoutedEventArgs e)
{
FolderPathLabel.Text = "폴더를 선택하세요";
FolderPathLabel.ToolTip = null;
lock (_convLock)
{
if (_currentConversation != null)
{
var session = ChatSession;
if (session != null)
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.WorkFolder = "", _storage);
else
_currentConversation.WorkFolder = "";
}
}
}
private void UpdateFolderBar()
{
if (FolderBar == null) return;
if (_activeTab == "Chat")
{
FolderBar.Visibility = Visibility.Collapsed;
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
RefreshContextUsageVisual();
return;
}
FolderBar.Visibility = Visibility.Visible;
var folder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(folder))
{
FolderPathLabel.Text = folder;
FolderPathLabel.ToolTip = folder;
}
else
{
FolderPathLabel.Text = "폴더를 선택하세요";
FolderPathLabel.ToolTip = null;
}
// 대화별 설정 복원 (없으면 전역 기본값)
LoadConversationSettings();
UpdatePermissionUI();
UpdateDataUsageUI();
RefreshContextUsageVisual();
ScheduleGitBranchRefresh();
}
/// <summary>현재 대화의 개별 설정을 로드합니다. null이면 전역 기본값 사용.</summary>
private void LoadConversationSettings()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
var llm = _settings.Settings.Llm;
var fallbackPermission = _activeTab == "Chat"
? PermissionModeCatalog.Deny
: PermissionModeCatalog.NormalizeGlobalMode(llm.DefaultAgentPermission);
var conversationPermission = !string.IsNullOrWhiteSpace(conv?.Permission)
? PermissionModeCatalog.NormalizeGlobalMode(conv.Permission)
: fallbackPermission;
_settings.Settings.Llm.FilePermission = conversationPermission;
_folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "none";
_selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern";
}
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
private void SaveConversationSettings()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
try
{
conv.Permission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
conv.DataUsage = _folderDataUsage;
conv.Mood = _selectedMood;
var session = ChatSession;
if (session != null)
{
lock (_convLock)
_currentConversation = session.SaveConversationSettings(_activeTab, _settings.Settings.Llm.FilePermission, _folderDataUsage, conv.OutputFormat, _selectedMood, _storage);
}
else
{
_storage.Save(conv);
}
}
catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
}
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
private void BtnPermission_Click(object sender, RoutedEventArgs e)
{
if (PermissionPopup == null) return;
PermissionItems.Children.Clear();
ChatConversation? currentConversation;
lock (_convLock) currentConversation = _currentConversation;
var summary = _appState.GetPermissionSummary(currentConversation);
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
Border CreateCollapsibleSection(string sectionKey, string icon, string title, UIElement content, bool expanded, string accentHex = "#334155")
{
var body = new Border
{
Margin = new Thickness(0, 5, 0, 0),
Visibility = expanded ? Visibility.Visible : Visibility.Collapsed,
Child = content,
};
var caret = new TextBlock
{
Text = expanded ? "\uE70D" : "\uE76C",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10.5,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
};
var headerGrid = new Grid();
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10.5,
Foreground = BrushFromHex(accentHex),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
});
var titleBlock = new TextBlock
{
Text = title,
FontSize = 9.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(titleBlock, 1);
headerGrid.Children.Add(titleBlock);
Grid.SetColumn(caret, 2);
headerGrid.Children.Add(caret);
var headerBorder = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(8, 5, 8, 5),
Cursor = Cursors.Hand,
Focusable = true,
Child = headerGrid,
};
KeyboardNavigation.SetIsTabStop(headerBorder, true);
headerBorder.MouseEnter += (_, _) => headerBorder.Background = BrushFromHex("#F8FAFC");
headerBorder.MouseLeave += (_, _) => headerBorder.Background = Brushes.Transparent;
void ToggleSection()
{
var show = body.Visibility != Visibility.Visible;
body.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
caret.Text = show ? "\uE70D" : "\uE76C";
SetPermissionPopupSectionExpanded(sectionKey, show);
}
headerBorder.MouseLeftButtonUp += (_, _) => ToggleSection();
headerBorder.KeyDown += (_, ke) =>
{
if (ke.Key is Key.Enter or Key.Space)
{
ke.Handled = true;
ToggleSection();
}
};
return new Border
{
Background = Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(0, 2, 0, 2),
Margin = new Thickness(0, 0, 0, 2),
Child = new StackPanel
{
Children =
{
headerBorder,
body,
}
}
};
}
var summaryCard = new StackPanel
{
Margin = new Thickness(0, 0, 0, 4),
Children =
{
CreateFlatPopupRow(
"\uE946",
$"현재 모드 · {PermissionModeCatalog.ToDisplayLabel(summary.EffectiveMode)}",
summary.Description,
string.Equals(summary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) ? "#C2410C" :
string.Equals(summary.RiskLevel, "locked", StringComparison.OrdinalIgnoreCase) ? "#475569" : "#4338CA",
false,
null),
CreateFlatPopupRow(
"\uE8D7",
$"기본값 · {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)}",
$"예외 {summary.OverrideCount}개",
"#64748B",
false,
null)
}
};
StackPanel? overrideSection = null;
if (summary.TopOverrides.Count > 0)
{
var overrideWrap = new StackPanel
{
Margin = new Thickness(0, 0, 0, 5),
};
foreach (var overrideEntry in summary.TopOverrides)
{
overrideWrap.Children.Add(CreateFlatPopupRow(
"\uE72E",
overrideEntry.Key,
PermissionModeCatalog.ToDisplayLabel(overrideEntry.Value),
"#2563EB",
false,
null));
}
overrideSection = new StackPanel
{
Margin = new Thickness(0, 0, 0, 8),
Children =
{
new TextBlock
{
Text = "도구별 예외",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(2, 0, 0, 3),
},
overrideWrap
}
};
}
var latestDenied = _appState.GetLatestDeniedPermission();
Border? deniedCard = null;
if (latestDenied != null)
{
var deniedStack = new StackPanel();
deniedStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
Children =
{
new TextBlock
{
Text = "\uEA39",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10.5,
Foreground = BrushFromHex("#991B1B"),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = "최근 권한 거부",
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#991B1B"),
}
}
});
deniedStack.Children.Add(new TextBlock
{
Text = _appState.FormatPermissionEventLine(latestDenied),
FontSize = 10,
Foreground = BrushFromHex("#991B1B"),
Margin = new Thickness(0, 0, 0, 0),
TextWrapping = TextWrapping.Wrap,
LineHeight = 14,
MaxWidth = 250,
});
if (!string.IsNullOrWhiteSpace(latestDenied.ToolName))
{
deniedStack.Children.Add(new TextBlock
{
Text = $"도구 {latestDenied.ToolName}에 바로 적용",
FontSize = 9.5,
Margin = new Thickness(0, 4, 0, 0),
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
});
var actionRow = new StackPanel
{
Margin = new Thickness(0, 6, 0, 0),
};
actionRow.Children.Add(CreateFlatPopupRow(
"\uE711",
"읽기 전용",
"이 도구의 쓰기 작업을 차단합니다",
"#991B1B",
true,
() => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny); RefreshPermissionPopup(); }));
actionRow.Children.Add(CreateFlatPopupRow(
"\uE8D7",
"권한 요청",
"실행 전 항상 확인받도록 되돌립니다",
"#1D4ED8",
true,
() => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default); RefreshPermissionPopup(); }));
actionRow.Children.Add(CreateFlatPopupRow(
"\uE73E",
"편집 자동 승인",
"이 도구의 편집 작업을 자동 허용합니다",
"#166534",
true,
() => { SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits); RefreshPermissionPopup(); }));
actionRow.Children.Add(CreateFlatPopupRow(
"\uE74D",
"예외 해제",
"도구별 예외를 제거하고 기본값을 사용합니다",
"#374151",
true,
() => { SetToolPermissionOverride(latestDenied.ToolName!, null); RefreshPermissionPopup(); }));
deniedStack.Children.Add(actionRow);
}
deniedCard = new Border
{
Background = Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(8, 8, 8, 8),
Margin = new Thickness(0, 0, 0, 4),
Child = deniedStack,
};
}
var coreLevels = PermissionModePresentationCatalog.Ordered.ToList();
var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
void AddPermissionRows(Panel container, IEnumerable<PermissionModePresentation> levels)
{
foreach (var item in levels)
{
var level = item.Mode;
var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase);
var rowBorder = new Border
{
Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent,
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(8, 8, 8, 8),
Margin = new Thickness(0, 0, 0, 0),
Cursor = Cursors.Hand,
Focusable = true,
};
KeyboardNavigation.SetIsTabStop(rowBorder, true);
var row = new Grid();
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
row.Children.Add(new TextBlock
{
Text = item.Icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = BrushFromHex(item.ColorHex),
Margin = new Thickness(1, 0, 8, 0),
VerticalAlignment = VerticalAlignment.Center,
});
var textStack = new StackPanel();
textStack.Children.Add(new TextBlock
{
Text = item.Title,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
});
textStack.Children.Add(new TextBlock
{
Text = item.Description,
FontSize = 10.5,
Margin = new Thickness(0, 1, 0, 0),
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextWrapping = TextWrapping.Wrap,
LineHeight = 15,
MaxWidth = 240,
});
Grid.SetColumn(textStack, 1);
row.Children.Add(textStack);
var check = new TextBlock
{
Text = isActive ? "\uE73E" : "",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
FontWeight = FontWeights.Bold,
Foreground = BrushFromHex("#2563EB"),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 1, 0),
};
Grid.SetColumn(check, 2);
row.Children.Add(check);
rowBorder.Child = row;
rowBorder.MouseEnter += (_, _) =>
{
rowBorder.Background = BrushFromHex("#F8FAFC");
rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0");
};
rowBorder.MouseLeave += (_, _) =>
{
rowBorder.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent;
rowBorder.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB");
};
var capturedLevel = level;
void ApplyPermission()
{
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel);
try { _settings.Save(); } catch { }
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
RefreshOverlayModeButtons();
PermissionPopup.IsOpen = false;
}
rowBorder.MouseLeftButtonDown += (_, _) => ApplyPermission();
rowBorder.KeyDown += (_, ke) =>
{
if (ke.Key is Key.Enter or Key.Space)
{
ke.Handled = true;
ApplyPermission();
}
};
container.Children.Add(rowBorder);
}
}
PermissionItems.Children.Add(CreatePopupSectionLabel("핵심 권한 모드"));
AddPermissionRows(PermissionItems, coreLevels);
// claw-code 기준 UX 정렬: 기본 화면은 핵심 모드 중심, 부가 정보는 단일 상세 섹션으로 제공.
var detailsPanel = new StackPanel();
detailsPanel.Children.Add(summaryCard);
if (overrideSection != null)
detailsPanel.Children.Add(overrideSection);
if (deniedCard != null)
detailsPanel.Children.Add(deniedCard);
PermissionItems.Children.Add(CreateCollapsibleSection(
"permission_details",
"\uE946",
"상세 정보",
detailsPanel,
expanded: GetPermissionPopupSectionExpanded("permission_details", false)));
PermissionPopup.IsOpen = true;
Dispatcher.BeginInvoke(() =>
{
TryFocusFirstPermissionElement(PermissionItems);
}, DispatcherPriority.Input);
}
private static bool TryFocusFirstPermissionElement(DependencyObject root)
{
if (root is UIElement ui && ui.Focusable && ui.IsEnabled && ui.Visibility == Visibility.Visible)
return ui.Focus();
var childCount = VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < childCount; i++)
{
var child = VisualTreeHelper.GetChild(root, i);
if (TryFocusFirstPermissionElement(child))
return true;
}
return false;
}
private void SetToolPermissionOverride(string toolName, string? mode)
{
if (string.IsNullOrWhiteSpace(toolName)) return;
var toolPermissions = _settings.Settings.Llm.ToolPermissions ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var existingKey = toolPermissions.Keys.FirstOrDefault(x => string.Equals(x, toolName, StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(mode))
{
if (!string.IsNullOrWhiteSpace(existingKey))
toolPermissions.Remove(existingKey!);
}
else
{
toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode);
}
try { _settings.Save(); } catch { }
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
}
private void RefreshPermissionPopup()
{
if (PermissionPopup == null) return;
BtnPermission_Click(this, new RoutedEventArgs());
}
private string _lastPermissionBannerMode = "";
private bool GetPermissionPopupSectionExpanded(string sectionKey, bool defaultValue = false)
{
var map = _settings.Settings.Llm.PermissionPopupSections;
if (map != null && map.TryGetValue(sectionKey, out var expanded))
return expanded;
return defaultValue;
}
private void SetPermissionPopupSectionExpanded(string sectionKey, bool expanded)
{
var map = _settings.Settings.Llm.PermissionPopupSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
map[sectionKey] = expanded;
try { _settings.Save(); } catch { }
}
private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e)
{
if (PermissionTopBanner != null)
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
private void UpdatePermissionUI()
{
if (PermissionLabel == null || PermissionIcon == null) return;
ChatConversation? currentConversation;
lock (_convLock) currentConversation = _currentConversation;
var summary = _appState.GetPermissionSummary(currentConversation);
var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm);
PermissionIcon.Text = perm switch
{
"AcceptEdits" => "\uE73E",
"Plan" => "\uE7C3",
"BypassPermissions" => "\uE7BA",
"Deny" => "\uE711",
_ => "\uE8D7",
};
if (BtnPermission != null)
{
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
BtnPermission.ToolTip = $"{summary.Description}\n운영 모드: {operationMode}\n기본값 {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개";
}
if (!string.Equals(_lastPermissionBannerMode, perm, StringComparison.OrdinalIgnoreCase))
{
_lastPermissionBannerMode = perm;
}
// 모드별 색상 + 상단 권한 배너 표시
if (perm == PermissionModeCatalog.AcceptEdits)
{
var activeColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
PermissionLabel.Foreground = activeColor;
PermissionIcon.Foreground = activeColor;
if (PermissionTopBanner != null)
{
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
PermissionTopBannerIcon.Text = "\uE73E";
PermissionTopBannerIcon.Foreground = activeColor;
PermissionTopBannerTitle.Text = "현재 권한 모드 · 편집 자동 승인";
PermissionTopBannerTitle.Foreground = BrushFromHex("#166534");
PermissionTopBannerText.Text = "모든 파일 편집을 자동 승인합니다. 명령 실행은 계속 확인합니다.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
}
else if (perm == PermissionModeCatalog.Deny)
{
var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10));
PermissionLabel.Foreground = denyColor;
PermissionIcon.Foreground = denyColor;
if (PermissionTopBanner != null)
{
PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC");
PermissionTopBannerIcon.Text = "\uE73E";
PermissionTopBannerIcon.Foreground = denyColor;
PermissionTopBannerTitle.Text = "현재 권한 모드 · 읽기 전용";
PermissionTopBannerTitle.Foreground = denyColor;
PermissionTopBannerText.Text = "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
}
else if (perm == PermissionModeCatalog.BypassPermissions)
{
var autoColor = new SolidColorBrush(Color.FromRgb(0xC2, 0x41, 0x0C));
PermissionLabel.Foreground = autoColor;
PermissionIcon.Foreground = autoColor;
if (PermissionTopBanner != null)
{
PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74");
PermissionTopBannerIcon.Text = "\uE814";
PermissionTopBannerIcon.Foreground = autoColor;
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기";
PermissionTopBannerTitle.Foreground = autoColor;
PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
}
else
{
var defaultFg = BrushFromHex("#2563EB");
var iconFg = perm switch
{
"Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)),
_ => new SolidColorBrush(Color.FromRgb(0x25, 0x63, 0xEB)),
};
PermissionLabel.Foreground = defaultFg;
PermissionIcon.Foreground = iconFg;
if (PermissionTopBanner != null)
{
if (perm == PermissionModeCatalog.Plan)
{
PermissionTopBanner.BorderBrush = BrushFromHex("#C7D2FE");
PermissionTopBannerIcon.Text = "\uE7C3";
PermissionTopBannerIcon.Foreground = BrushFromHex("#4338CA");
PermissionTopBannerTitle.Text = "현재 권한 모드 · 계획 모드";
PermissionTopBannerTitle.Foreground = BrushFromHex("#4338CA");
PermissionTopBannerText.Text = "변경 전에 계획을 먼저 만들고 승인 흐름을 우선합니다.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
else if (perm == PermissionModeCatalog.Default)
{
PermissionTopBanner.BorderBrush = BrushFromHex("#BFDBFE");
PermissionTopBannerIcon.Text = "\uE8D7";
PermissionTopBannerIcon.Foreground = BrushFromHex("#1D4ED8");
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 요청";
PermissionTopBannerTitle.Foreground = BrushFromHex("#1D4ED8");
PermissionTopBannerText.Text = "변경하기 전에 항상 확인합니다.";
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
else
{
PermissionTopBanner.Visibility = Visibility.Collapsed;
}
}
}
}
private bool TryApplyPermissionModeFromAction(string action, out string appliedMode)
{
appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
var next = action switch
{
"ask" => PermissionModeCatalog.Default,
"default" => PermissionModeCatalog.Default,
"acceptedits" => PermissionModeCatalog.AcceptEdits,
"accept" => PermissionModeCatalog.AcceptEdits,
"auto" => PermissionModeCatalog.AcceptEdits,
"plan" => PermissionModeCatalog.Plan,
"bypass" => PermissionModeCatalog.BypassPermissions,
"bypasspermissions" => PermissionModeCatalog.BypassPermissions,
"fullauto" => PermissionModeCatalog.BypassPermissions,
"dontask" => PermissionModeCatalog.BypassPermissions,
"silent" => PermissionModeCatalog.BypassPermissions,
_ => null,
};
if (string.IsNullOrWhiteSpace(next))
return false;
_settings.Settings.Llm.FilePermission = next!;
_settings.Save();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
appliedMode = next!;
return true;
}
private string BuildPermissionStatusText()
{
ChatConversation? currentConversation;
lock (_convLock) currentConversation = _currentConversation;
var summary = _appState.GetPermissionSummary(currentConversation);
var mode = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
var operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
var overrides = summary.TopOverrides.Count > 0
? string.Join(", ", summary.TopOverrides.Take(3).Select(x => $"{x.Key}:{PermissionModeCatalog.ToDisplayLabel(x.Value)}"))
: "없음";
return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)}\n운영 모드: {operationMode}\n기본값: {PermissionModeCatalog.ToDisplayLabel(summary.DefaultMode)} · 예외 {summary.OverrideCount}개\n예외 미리보기: {overrides}";
}
private void OpenPermissionPanelFromSlash(string command, string usageText)
{
BtnPermission_Click(this, new RoutedEventArgs());
AppendLocalSlashResult(_activeTab, command, $"권한 설정 팝업을 열었습니다. ({usageText})");
}
// ──── 데이터 활용 수준 메뉴 ────
private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (DataUsagePopup == null) return;
DataUsageItems.Children.Clear();
var options = new (string Key, string Sym, string Label, string Desc, string Color, string CheckColor)[]
{
("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽기만 포함해 참조하지 않습니다", "#6B7280", "#6B7280"),
("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706", "#D97706"),
("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10", "#107C10"),
};
foreach (var (key, sym, label, desc, color, checkColor) in options)
{
var isActive = key.Equals(_folderDataUsage, StringComparison.OrdinalIgnoreCase);
var row = new Border
{
Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent,
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 9, 8, 9),
Margin = new Thickness(0),
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
var checkIcon = CreateCheckIcon(isActive);
if (checkIcon is TextBlock checkText)
checkText.Foreground = isActive ? BrushFromHex(checkColor) : Brushes.Transparent;
sp.Children.Add(checkIcon);
sp.Children.Add(new TextBlock
{
Text = sym, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14,
Foreground = BrushFromHex(color),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
});
var textStack = new StackPanel();
textStack.Children.Add(new TextBlock
{
Text = label, FontSize = 11.5, FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(color),
});
textStack.Children.Add(new TextBlock
{
Text = desc, FontSize = 10.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextWrapping = TextWrapping.Wrap,
MaxWidth = 240,
});
sp.Children.Add(textStack);
row.Child = sp;
var capturedKey = key;
row.MouseEnter += (_, _) =>
{
row.Background = BrushFromHex("#F8FAFC");
row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0");
};
row.MouseLeave += (_, _) =>
{
row.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent;
row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB");
};
row.MouseLeftButtonDown += (_, _) =>
{
_folderDataUsage = capturedKey;
UpdateDataUsageUI();
SaveConversationSettings();
RefreshOverlayModeButtons();
DataUsagePopup.IsOpen = false;
};
DataUsageItems.Children.Add(row);
}
DataUsagePopup.IsOpen = true;
}
private void UpdateDataUsageUI()
{
if (DataUsageLabel == null || DataUsageIcon == null) return;
var (label, icon, color) = _folderDataUsage switch
{
"none" => ("미활용", "\uE8D8", "#6B7280"),
"passive" => ("소극", "\uE8FD", "#D97706"),
_ => ("적극", "\uE9F5", "#107C10"),
};
DataUsageLabel.Text = label;
DataUsageIcon.Text = icon;
DataUsageIcon.Foreground = BrushFromHex(color);
if (BtnDataUsage != null)
{
BtnDataUsage.Background = Brushes.Transparent;
BtnDataUsage.BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
BtnDataUsage.BorderThickness = new Thickness(1);
}
}
/// <summary>Cowork/Code 탭 진입 시 설정의 기본 권한을 적용.</summary>
private void ApplyTabDefaultPermission()
{
if (_activeTab == "Chat")
{
// Chat 탭: 경고 배너 숨기고 기본 제한 모드로 복원
_settings.Settings.Llm.FilePermission = PermissionModeCatalog.Deny;
UpdatePermissionUI();
return;
}
var defaultPerm = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.DefaultAgentPermission);
if (!string.IsNullOrEmpty(defaultPerm))
{
_settings.Settings.Llm.FilePermission = defaultPerm;
UpdatePermissionUI();
}
}
// ─── 파일 첨부 ─────────────────────────────────────────────────────────
private void BtnAttach_Click(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Multiselect = true,
Title = "첨부할 파일을 선택하세요",
Filter = "모든 파일 (*.*)|*.*|텍스트 (*.txt;*.md;*.csv)|*.txt;*.md;*.csv|코드 (*.cs;*.py;*.js;*.ts)|*.cs;*.py;*.js;*.ts",
};
// 작업 폴더가 있으면 초기 경로 설정
var workFolder = GetCurrentWorkFolder();
if (!string.IsNullOrEmpty(workFolder) && System.IO.Directory.Exists(workFolder))
dlg.InitialDirectory = workFolder;
if (dlg.ShowDialog() != true) return;
foreach (var file in dlg.FileNames)
AddAttachedFile(file);
}
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"
};
private void AddAttachedFile(string filePath)
{
if (_attachedFiles.Contains(filePath)) return;
// 파일 크기 제한 (10MB)
try
{
var fi = new System.IO.FileInfo(filePath);
if (fi.Length > 10 * 1024 * 1024)
{
CustomMessageBox.Show($"파일이 너무 큽니다 (10MB 초과):\n{fi.Name}", "첨부 제한", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// 이미지 파일 → Vision API용 base64 변환
var ext = fi.Extension.ToLowerInvariant();
if (ImageExtensions.Contains(ext) && _settings.Settings.Llm.EnableImageInput)
{
var maxKb = _settings.Settings.Llm.MaxImageSizeKb;
if (maxKb <= 0) maxKb = 5120;
if (fi.Length > maxKb * 1024)
{
CustomMessageBox.Show($"이미지가 너무 큽니다 ({fi.Length / 1024}KB, 최대 {maxKb}KB).",
"이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var bytes = System.IO.File.ReadAllBytes(filePath);
var mimeType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".bmp" => "image/bmp",
".webp" => "image/webp",
_ => "image/png",
};
var attachment = new ImageAttachment
{
Base64 = Convert.ToBase64String(bytes),
MimeType = mimeType,
FileName = fi.Name,
};
// 중복 확인
if (_pendingImages.Any(i => i.FileName == attachment.FileName)) return;
_pendingImages.Add(attachment);
AddImagePreview(attachment);
return;
}
}
catch { return; }
_attachedFiles.Add(filePath);
RefreshAttachedFilesUI();
UpdateConditionalSkillActivation();
}
private void RemoveAttachedFile(string filePath)
{
_attachedFiles.Remove(filePath);
RefreshAttachedFilesUI();
UpdateConditionalSkillActivation();
}
private void RefreshAttachedFilesUI()
{
AttachedFilesPanel.Items.Clear();
if (_attachedFiles.Count == 0)
{
AttachedFilesPanel.Visibility = Visibility.Collapsed;
return;
}
AttachedFilesPanel.Visibility = Visibility.Visible;
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
foreach (var file in _attachedFiles.ToList())
{
var fileName = System.IO.Path.GetFileName(file);
var capturedFile = file;
var chip = new Border
{
Background = hintBg,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(8, 4, 4, 4),
Margin = new Thickness(0, 0, 4, 4),
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = "\uE8A5", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 10,
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0),
});
sp.Children.Add(new TextBlock
{
Text = fileName, FontSize = 11, Foreground = secondaryBrush,
VerticalAlignment = VerticalAlignment.Center, MaxWidth = 150, TextTrimming = TextTrimming.CharacterEllipsis,
ToolTip = file,
});
var removeBtn = new Button
{
Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush },
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
};
removeBtn.Click += (_, _) => RemoveAttachedFile(capturedFile);
sp.Children.Add(removeBtn);
chip.Child = sp;
AttachedFilesPanel.Items.Add(chip);
}
}
/// <summary>첨부 파일 내용을 시스템 메시지로 변환합니다.</summary>
private string BuildFileContextPrompt()
{
if (_attachedFiles.Count == 0) return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n[첨부 파일 컨텍스트]");
foreach (var file in _attachedFiles)
{
try
{
var ext = System.IO.Path.GetExtension(file).ToLowerInvariant();
var isBinary = ext is ".exe" or ".dll" or ".zip" or ".7z" or ".rar" or ".tar" or ".gz"
or ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" or ".webp" or ".svg"
or ".pdf" or ".docx" or ".xlsx" or ".pptx" or ".doc" or ".xls" or ".ppt"
or ".mp3" or ".mp4" or ".avi" or ".mov" or ".mkv" or ".wav" or ".flac"
or ".psd" or ".ai" or ".sketch" or ".fig"
or ".msi" or ".iso" or ".img" or ".bin" or ".dat" or ".db" or ".sqlite";
if (isBinary)
{
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (바이너리 파일, 내용 생략) ---");
continue;
}
var content = System.IO.File.ReadAllText(file);
// 최대 8000자로 제한
if (content.Length > 8000)
content = content[..8000] + "\n... (이하 생략)";
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} ---");
sb.AppendLine(content);
}
catch (Exception ex)
{
sb.AppendLine($"\n--- {System.IO.Path.GetFileName(file)} (읽기 실패: {ex.Message}) ---");
}
}
return sb.ToString();
}
private void ResizeGrip_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e)
{
var newW = Width + e.HorizontalChange;
var newH = Height + e.VerticalChange;
if (newW >= MinWidth) Width = newW;
if (newH >= MinHeight) Height = newH;
}
// ─── 사이드바 토글 ───────────────────────────────────────────────────
private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e)
{
_sidebarVisible = !_sidebarVisible;
_tabSidebarVisible[_activeTab] = _sidebarVisible;
ApplySidebarStateForActiveTab(animated: true);
}
private void ApplySidebarStateForActiveTab(bool animated)
{
var targetVisible = _tabSidebarVisible.TryGetValue(_activeTab, out var visible) ? visible : true;
_sidebarVisible = targetVisible;
if (_sidebarVisible)
{
IconBarColumn.Width = new GridLength(0);
IconBarPanel.Visibility = Visibility.Collapsed;
SidebarPanel.Visibility = Visibility.Visible;
ToggleSidebarIcon.Text = "\uE76B";
if (animated)
{
AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200);
}
else
{
SidebarColumn.MinWidth = 200;
SidebarColumn.Width = new GridLength(270);
}
return;
}
SidebarColumn.MinWidth = 0;
ToggleSidebarIcon.Text = "\uE76C";
if (animated)
{
AnimateSidebar(270, 0, () =>
{
SidebarPanel.Visibility = Visibility.Collapsed;
IconBarColumn.Width = new GridLength(52);
IconBarPanel.Visibility = Visibility.Visible;
});
}
else
{
SidebarColumn.Width = new GridLength(0);
SidebarPanel.Visibility = Visibility.Collapsed;
IconBarColumn.Width = new GridLength(52);
IconBarPanel.Visibility = Visibility.Visible;
}
}
private void AnimateSidebar(double from, double to, Action? onComplete = null)
{
var duration = 200.0;
var start = DateTime.UtcNow;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) };
EventHandler tickHandler = null!;
tickHandler = (_, _) =>
{
var elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
var t = Math.Min(elapsed / duration, 1.0);
t = 1 - (1 - t) * (1 - t);
SidebarColumn.Width = new GridLength(from + (to - from) * t);
if (elapsed >= duration)
{
timer.Stop();
timer.Tick -= tickHandler;
SidebarColumn.Width = new GridLength(to);
onComplete?.Invoke();
}
};
timer.Tick += tickHandler;
timer.Start();
}
// ─── 대화 목록 ────────────────────────────────────────────────────────
public void RefreshConversationList()
{
var metas = _storage.LoadAllMeta();
// 프리셋 카테고리 → 아이콘/색상 매핑 (ChatCategory에 없는 코워크/코드 카테고리 지원)
var allPresets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
.Concat(Services.PresetService.GetByTabWithCustom("Code", _settings.Settings.Llm.CustomPresets))
.Concat(Services.PresetService.GetByTabWithCustom("Chat", _settings.Settings.Llm.CustomPresets));
var presetMap = new Dictionary<string, (string Symbol, string Color)>(StringComparer.OrdinalIgnoreCase);
foreach (var p in allPresets)
presetMap.TryAdd(p.Category, (p.Symbol, p.Color));
var items = metas.Select(c =>
{
var symbol = ChatCategory.GetSymbol(c.Category);
var color = ChatCategory.GetColor(c.Category);
// ChatCategory 기본값이면 프리셋에서 검색
if (symbol == "\uE8BD" && color == "#6B7280" && c.Category != ChatCategory.General)
{
if (presetMap.TryGetValue(c.Category, out var pm))
{
symbol = pm.Symbol;
color = pm.Color;
}
}
var runSummary = _appState.GetConversationRunSummary(c.AgentRunHistory);
return new ConversationMeta
{
Id = c.Id,
Title = c.Title,
Pinned = c.Pinned,
Category = c.Category,
Symbol = symbol,
ColorHex = color,
Tab = NormalizeTabName(c.Tab),
UpdatedAtText = FormatDate(c.UpdatedAt),
UpdatedAt = c.UpdatedAt,
Preview = c.Preview ?? "",
ParentId = c.ParentId,
AgentRunCount = runSummary.AgentRunCount,
FailedAgentRunCount = runSummary.FailedAgentRunCount,
LastAgentRunSummary = runSummary.LastAgentRunSummary,
LastFailedAt = runSummary.LastFailedAt,
LastCompletedAt = runSummary.LastCompletedAt,
WorkFolder = c.WorkFolder ?? "",
IsRunning = _currentConversation?.Id == c.Id
&& !string.IsNullOrWhiteSpace(_appState.AgentRun.RunId)
&& !string.Equals(_appState.AgentRun.Status, "completed", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_appState.AgentRun.Status, "failed", StringComparison.OrdinalIgnoreCase),
};
}).ToList();
// 탭 필터 — 현재 활성 탭의 대화만 표시
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList();
// 탭 전환 과정에서 저장된 "빈 새 대화" 노이즈 항목은 목록에서 숨김
items = items.Where(i =>
i.Pinned
|| !string.IsNullOrWhiteSpace(i.ParentId)
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|| !string.IsNullOrWhiteSpace(i.Preview)
|| i.AgentRunCount > 0
|| i.FailedAgentRunCount > 0
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)
).ToList();
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
_runningConversationCount = items.Count(i => i.IsRunning);
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi();
UpdateConversationQuickStripUi();
// 상단 필터 적용
if (_activeTab == "Cowork" || _activeTab == "Code")
{
if (!string.IsNullOrEmpty(_selectedCategory))
{
items = items.Where(i => string.Equals(i.WorkFolder, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList();
}
}
else
{
if (_selectedCategory == "__custom__")
{
// 커스텀 프리셋으로 만든 대화만 표시
var customCats = _settings.Settings.Llm.CustomPresets
.Select(c => $"custom_{c.Id}").ToHashSet();
items = items.Where(i => customCats.Contains(i.Category)).ToList();
}
else if (!string.IsNullOrEmpty(_selectedCategory))
{
items = items.Where(i => i.Category == _selectedCategory).ToList();
}
}
// 검색 필터 (제목 + 내용 미리보기)
var search = SearchBox?.Text?.Trim() ?? "";
if (!string.IsNullOrEmpty(search))
items = items.Where(i =>
i.Title.Contains(search, StringComparison.OrdinalIgnoreCase) ||
i.Preview.Contains(search, StringComparison.OrdinalIgnoreCase) ||
i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase)
).ToList();
if (_runningOnlyFilter)
items = items.Where(i => i.IsRunning).ToList();
items = (_sortConversationsByRecent
? items.OrderByDescending(i => i.Pinned)
.ThenByDescending(i => i.UpdatedAt)
.ThenByDescending(i => i.FailedAgentRunCount > 0)
.ThenByDescending(i => i.AgentRunCount)
: items.OrderByDescending(i => i.Pinned)
.ThenByDescending(i => i.FailedAgentRunCount > 0)
.ThenByDescending(i => i.AgentRunCount)
.ThenByDescending(i => i.UpdatedAt))
.ToList();
RenderConversationList(items);
}
private const int ConversationPageSize = 50;
private List<ConversationMeta>? _pendingConversations;
private void RenderConversationList(List<ConversationMeta> items)
{
ConversationPanel.Children.Clear();
_pendingConversations = null;
if (items.Count == 0)
{
var emptyText = _activeTab switch
{
"Cowork" => "Cowork 탭 대화가 없습니다",
"Code" => "Code 탭 대화가 없습니다",
_ => "Chat 탭 대화가 없습니다",
};
var empty = new TextBlock
{
Text = emptyText,
FontSize = 12,
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 20, 0, 0)
};
ConversationPanel.Children.Add(empty);
return;
}
var spotlightItems = BuildConversationSpotlightItems(items);
if (spotlightItems.Count > 0)
{
AddGroupHeader("집중 필요");
foreach (var item in spotlightItems)
AddConversationItem(item);
ConversationPanel.Children.Add(new Border
{
Height = 1,
Margin = new Thickness(10, 8, 10, 4),
Background = BrushFromHex("#E5E7EB"),
Opacity = 0.7,
});
}
var allOrdered = new List<(string Group, ConversationMeta Item)>();
foreach (var item in items)
allOrdered.Add((GetConversationDateGroup(item.UpdatedAt), item));
// 첫 페이지만 렌더링
var firstPage = allOrdered.Take(ConversationPageSize).ToList();
string? lastGroup = null;
foreach (var (group, item) in firstPage)
{
if (group != lastGroup) { AddGroupHeader(group); lastGroup = group; }
AddConversationItem(item);
}
// 나머지가 있으면 "더 보기" 버튼
if (allOrdered.Count > ConversationPageSize)
{
_pendingConversations = items;
AddLoadMoreButton(allOrdered.Count - ConversationPageSize);
}
}
private void AddLoadMoreButton(int remaining)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var btn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 10, 8, 10),
Margin = new Thickness(6, 4, 6, 4),
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var sp = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
sp.Children.Add(new TextBlock
{
Text = $"더 보기 ({remaining}개 남음)",
FontSize = 12,
Foreground = accentBrush,
HorizontalAlignment = HorizontalAlignment.Center,
});
btn.Child = sp;
btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
btn.MouseLeftButtonUp += (_, _) =>
{
// 전체 목록 렌더링
if (_pendingConversations != null)
{
var all = _pendingConversations;
_pendingConversations = null;
ConversationPanel.Children.Clear();
string? lastGroup = null;
foreach (var item in all)
{
var group = GetConversationDateGroup(item.UpdatedAt);
if (!string.Equals(lastGroup, group, StringComparison.Ordinal))
{
AddGroupHeader(group);
lastGroup = group;
}
AddConversationItem(item);
}
}
};
ConversationPanel.Children.Add(btn);
}
private static string GetConversationDateGroup(DateTime updatedAt)
{
var today = DateTime.Today;
var date = updatedAt.Date;
if (date == today)
return "오늘";
if (date == today.AddDays(-1))
return "어제";
return "이전";
}
private List<ConversationMeta> BuildConversationSpotlightItems(List<ConversationMeta> items)
{
if (_failedOnlyFilter || _runningOnlyFilter)
return new List<ConversationMeta>();
var search = SearchBox?.Text?.Trim() ?? "";
if (!string.IsNullOrEmpty(search))
return new List<ConversationMeta>();
return items
.Where(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3)
.OrderByDescending(i => i.FailedAgentRunCount)
.ThenByDescending(i => i.AgentRunCount)
.ThenByDescending(i => i.UpdatedAt)
.Take(3)
.ToList();
}
private void AddGroupHeader(string text)
{
var header = new TextBlock
{
Text = text,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
Margin = new Thickness(8, 10, 0, 3)
};
ConversationPanel.Children.Add(header);
}
private void AddConversationItem(ConversationMeta item)
{
var isSelected = false;
lock (_convLock)
isSelected = _currentConversation?.Id == item.Id;
var isBranch = !string.IsNullOrEmpty(item.ParentId);
var border = new Border
{
Background = isSelected
? new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC))
: Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = isBranch ? new Thickness(14, 1, 0, 1) : new Thickness(0, 1, 0, 1),
Cursor = Cursors.Hand
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
// 카테고리 아이콘 (고정 시 핀 아이콘, 그 외 카테고리 색상)
Brush iconBrush;
if (item.Pinned)
iconBrush = Brushes.Orange;
else
{
try { iconBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(item.ColorHex)); }
catch { iconBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; }
}
var iconText = item.Pinned ? "\uE718" : !string.IsNullOrEmpty(item.ParentId) ? "\uE8A5" : item.Symbol;
if (!string.IsNullOrEmpty(item.ParentId)) iconBrush = new SolidColorBrush(Color.FromRgb(0x8B, 0x5C, 0xF6)); // 분기: 보라색
var icon = new TextBlock
{
Text = iconText,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = iconBrush,
VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(icon, 0);
grid.Children.Add(icon);
// 제목 + 날짜 (선택 시 약간 밝게)
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var dateColor = TryFindResource("HintText") as Brush ?? Brushes.DarkGray;
var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
var title = new TextBlock
{
Text = item.Title,
FontSize = 12,
Foreground = titleColor,
TextTrimming = TextTrimming.CharacterEllipsis
};
var date = new TextBlock
{
Text = item.UpdatedAtText,
FontSize = 9.5,
Foreground = dateColor,
Margin = new Thickness(0, 1, 0, 0)
};
stack.Children.Add(title);
stack.Children.Add(date);
if (item.IsRunning)
{
stack.Children.Add(new Border
{
Background = BrushFromHex("#DBEAFE"),
BorderBrush = BrushFromHex("#93C5FD"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(6, 1, 6, 1),
Margin = new Thickness(0, 4, 0, 0),
HorizontalAlignment = HorizontalAlignment.Left,
Child = new TextBlock
{
Text = _appState.ActiveTasks.Count > 0
? $"진행 중 {_appState.ActiveTasks.Count}"
: "진행 중",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1D4ED8"),
}
});
}
if (item.AgentRunCount > 0)
{
var runSummaryPanel = new DockPanel
{
Margin = new Thickness(0, 3, 0, 0),
LastChildFill = true,
};
if (item.FailedAgentRunCount > 0 && item.LastFailedAt.HasValue)
{
var failedBadge = new Border
{
Background = BrushFromHex("#FEF2F2"),
BorderBrush = BrushFromHex("#FECACA"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(6, 1, 6, 1),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = $"실패 {FormatDate(item.LastFailedAt.Value)}",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#991B1B"),
}
};
DockPanel.SetDock(failedBadge, Dock.Right);
runSummaryPanel.Children.Add(failedBadge);
}
else if (item.LastCompletedAt.HasValue)
{
var completedBadge = new Border
{
Background = BrushFromHex("#ECFDF5"),
BorderBrush = BrushFromHex("#BBF7D0"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(6, 1, 6, 1),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = $"성공 {FormatDate(item.LastCompletedAt.Value)}",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#166534"),
}
};
DockPanel.SetDock(completedBadge, Dock.Right);
runSummaryPanel.Children.Add(completedBadge);
}
var runSummaryText = new TextBlock
{
Text = item.FailedAgentRunCount > 0
? $"실행 {item.AgentRunCount} · 실패 {item.FailedAgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 28)}"
: $"실행 {item.AgentRunCount} · {TruncateForStatus(item.LastAgentRunSummary, 32)}",
FontSize = 9.5,
Foreground = item.FailedAgentRunCount > 0
? BrushFromHex("#B91C1C")
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
Margin = new Thickness(0, 3, 0, 0),
TextTrimming = TextTrimming.CharacterEllipsis
};
if (!string.IsNullOrWhiteSpace(item.LastAgentRunSummary))
{
runSummaryText.ToolTip = item.FailedAgentRunCount > 0
? $"최근 실패 포함\n{item.LastAgentRunSummary}"
: item.LastAgentRunSummary;
}
runSummaryPanel.Children.Add(runSummaryText);
stack.Children.Add(runSummaryPanel);
}
Grid.SetColumn(stack, 1);
grid.Children.Add(stack);
// 카테고리 변경 버튼 (호버 시 표시)
var catBtn = new Button
{
Content = new TextBlock
{
Text = "\uE70F", // Edit
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray)
},
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Visibility = Visibility.Collapsed,
Padding = new Thickness(4),
ToolTip = _activeTab == "Cowork" ? "작업 유형" : "대화 주제 변경"
};
var capturedId = item.Id;
catBtn.Click += (_, _) => ShowConversationMenu(capturedId);
Grid.SetColumn(catBtn, 2);
grid.Children.Add(catBtn);
// 선택 시 좌측 액센트 바
if (isSelected)
{
border.BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
border.BorderThickness = new Thickness(2, 0, 0, 0);
}
border.Child = grid;
// 호버 이벤트 — 배경 + 미세 확대
border.RenderTransformOrigin = new Point(0.5, 0.5);
border.RenderTransform = new ScaleTransform(1, 1);
var selectedBg = new SolidColorBrush(Color.FromArgb(0x30, 0x4B, 0x5E, 0xFC));
border.MouseEnter += (_, _) =>
{
if (!isSelected)
border.Background = new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF));
catBtn.Visibility = Visibility.Visible;
var st = border.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
};
border.MouseLeave += (_, _) =>
{
if (!isSelected)
border.Background = Brushes.Transparent;
catBtn.Visibility = Visibility.Collapsed;
var st = border.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
};
// 클릭 — 이미 선택된 대화면 제목 편집, 아니면 대화 전환
border.MouseLeftButtonDown += (_, _) =>
{
try
{
if (isSelected)
{
// 이미 선택된 대화 → 제목 편집 모드
EnterTitleEditMode(title, item.Id, titleColor);
return;
}
// 스트리밍 중이면 취소
if (_isStreaming)
{
_streamCts?.Cancel();
_cursorTimer.Stop();
_typingTimer.Stop();
_elapsedTimer.Stop();
_activeStreamText = null;
_elapsedLabel = null;
_isStreaming = false;
}
var conv = _storage.Load(item.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshConversationList();
RefreshDraftQueueUi();
}
}
catch (Exception ex)
{
LogService.Error($"대화 전환 오류: {ex.Message}");
}
};
// 우클릭 → 대화 관리 메뉴 바로 표시
border.MouseRightButtonUp += (_, me) =>
{
me.Handled = true;
// 선택되지 않은 대화를 우클릭하면 먼저 선택
if (!isSelected)
{
var conv = _storage.Load(item.Id);
if (conv != null)
{
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RenderMessages();
RefreshDraftQueueUi();
}
}
// Dispatcher로 지연 호출 — 마우스 이벤트 완료 후 Popup 열기
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
};
ConversationPanel.Children.Add(border);
}
// ─── 대화 제목 인라인 편집 ────────────────────────────────────────────
private void EnterTitleEditMode(TextBlock titleTb, string conversationId, Brush titleColor)
{
try
{
// titleTb가 이미 부모에서 분리된 경우(편집 중) 무시
var parent = titleTb.Parent as StackPanel;
if (parent == null) return;
var idx = parent.Children.IndexOf(titleTb);
if (idx < 0) return;
var editBox = new TextBox
{
Text = titleTb.Text,
FontSize = 12.5,
Foreground = titleColor,
Background = Brushes.Transparent,
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
BorderThickness = new Thickness(0, 0, 0, 1),
CaretBrush = titleColor,
Padding = new Thickness(0),
Margin = new Thickness(0),
};
// 안전하게 자식 교체: 먼저 제거 후 삽입
parent.Children.RemoveAt(idx);
parent.Children.Insert(idx, editBox);
var committed = false;
void CommitEdit()
{
if (committed) return;
committed = true;
var newTitle = editBox.Text.Trim();
if (string.IsNullOrEmpty(newTitle)) newTitle = titleTb.Text;
titleTb.Text = newTitle;
// editBox가 아직 parent에 있는지 확인 후 교체
try
{
var currentIdx = parent.Children.IndexOf(editBox);
if (currentIdx >= 0)
{
parent.Children.RemoveAt(currentIdx);
parent.Children.Insert(currentIdx, titleTb);
}
}
catch { /* 부모가 이미 해제된 경우 무시 */ }
var conv = _storage.Load(conversationId);
if (conv != null)
{
conv.Title = newTitle;
_storage.Save(conv);
lock (_convLock)
{
if (_currentConversation?.Id == conversationId)
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage) ?? _currentConversation;
}
UpdateChatTitle();
}
}
void CancelEdit()
{
if (committed) return;
committed = true;
try
{
var currentIdx = parent.Children.IndexOf(editBox);
if (currentIdx >= 0)
{
parent.Children.RemoveAt(currentIdx);
parent.Children.Insert(currentIdx, titleTb);
}
}
catch { /* 부모가 이미 해제된 경우 무시 */ }
}
editBox.KeyDown += (_, ke) =>
{
if (ke.Key == Key.Enter) { ke.Handled = true; CommitEdit(); }
if (ke.Key == Key.Escape) { ke.Handled = true; CancelEdit(); }
};
editBox.LostFocus += (_, _) => CommitEdit();
editBox.Focus();
editBox.SelectAll();
}
catch (Exception ex)
{
LogService.Error($"제목 편집 오류: {ex.Message}");
}
}
// ─── 카테고리 변경 팝업 ──────────────────────────────────────────────
private void ShowConversationMenu(string conversationId)
{
var conv = _storage.Load(conversationId);
var isPinned = conv?.Pinned ?? false;
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1A, 0x1B, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var popup = new Popup
{
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Placement = PlacementMode.MousePoint,
};
var container = new Border
{
Background = bgBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3, Color = Colors.Black
},
};
var stack = new StackPanel();
// 메뉴 항목 헬퍼
Border CreateMenuItem(string icon, string text, Brush iconColor, Action onClick)
{
var item = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 1, 0, 1),
Cursor = Cursors.Hand,
};
var g = new Grid();
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var iconTb = new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = iconColor, VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(iconTb, 0);
g.Children.Add(iconTb);
var textTb = new TextBlock
{
Text = text, FontSize = 12.5, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(textTb, 1);
g.Children.Add(textTb);
item.Child = g;
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 += (_, _) => { popup.IsOpen = false; onClick(); };
return item;
}
Border CreateSeparator() => new()
{
Height = 1, Background = borderBrush, Opacity = 0.3, Margin = new Thickness(8, 4, 8, 4),
};
// 고정/해제
stack.Children.Add(CreateMenuItem(
isPinned ? "\uE77A" : "\uE718",
isPinned ? "고정 해제" : "상단 고정",
TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
() =>
{
var c = _storage.Load(conversationId);
if (c != null)
{
c.Pinned = !c.Pinned;
_storage.Save(c);
lock (_convLock)
{
if (_currentConversation?.Id == conversationId)
{
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current => current.Pinned = c.Pinned, _storage) ?? _currentConversation;
}
}
RefreshConversationList();
}
}));
// 이름 변경
stack.Children.Add(CreateMenuItem("\uE8AC", "이름 변경", secondaryText, () =>
{
// 대화 목록에서 해당 항목 찾아서 편집 모드 진입
foreach (UIElement child in ConversationPanel.Children)
{
if (child is Border b && b.Child is Grid g)
{
foreach (UIElement gc in g.Children)
{
if (gc is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
{
// title과 매칭
if (conv != null && tb.Text == conv.Title)
{
var titleColor = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
EnterTitleEditMode(tb, conversationId, titleColor);
return;
}
}
}
}
}
}));
// Cowork/Code 탭: 작업 유형 읽기 전용 표시
if ((_activeTab == "Cowork" || _activeTab == "Code") && conv != null)
{
var catKey = conv.Category ?? ChatCategory.General;
// ChatCategory 또는 프리셋에서 아이콘/라벨 검색
string catSymbol = "\uE8BD", catLabel = catKey, catColor = "#6B7280";
var chatCat = ChatCategory.All.FirstOrDefault(c => c.Key == catKey);
if (chatCat != default && chatCat.Key != ChatCategory.General)
{
catSymbol = chatCat.Symbol; catLabel = chatCat.Label; catColor = chatCat.Color;
}
else
{
var preset = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets)
.FirstOrDefault(p => p.Category == catKey);
if (preset != null)
{
catSymbol = preset.Symbol; catLabel = preset.Label; catColor = preset.Color;
}
}
stack.Children.Add(CreateSeparator());
var infoSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(10, 4, 10, 4) };
try
{
var catBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(catColor));
infoSp.Children.Add(new TextBlock
{
Text = catSymbol, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = catBrush,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
});
infoSp.Children.Add(new TextBlock
{
Text = catLabel, FontSize = 12, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
}
catch
{
infoSp.Children.Add(new TextBlock { Text = catLabel, FontSize = 12, Foreground = primaryText });
}
stack.Children.Add(infoSp);
}
// Chat 탭만 분류 변경 표시 (Cowork/Code 탭은 분류 불필요)
var showCategorySection = _activeTab == "Chat";
if (showCategorySection)
{
stack.Children.Add(CreateSeparator());
// 분류 헤더
stack.Children.Add(new TextBlock
{
Text = "분류 변경",
FontSize = 10.5,
Foreground = secondaryText,
Margin = new Thickness(10, 4, 0, 4),
FontWeight = FontWeights.SemiBold,
});
var currentCategory = conv?.Category ?? ChatCategory.General;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
foreach (var (key, label, symbol, color) in ChatCategory.All)
{
var capturedKey = key;
var isCurrentCat = capturedKey == currentCategory;
// 카테고리 항목 (체크 표시 포함)
var catItem = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 1, 0, 1),
Cursor = Cursors.Hand,
};
var catGrid = new Grid();
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) });
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
catGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(20) });
var catIcon = new TextBlock
{
Text = symbol, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = BrushFromHex(color), VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(catIcon, 0);
catGrid.Children.Add(catIcon);
var catText = new TextBlock
{
Text = label, FontSize = 12.5, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
FontWeight = isCurrentCat ? FontWeights.Bold : FontWeights.Normal,
};
Grid.SetColumn(catText, 1);
catGrid.Children.Add(catText);
if (isCurrentCat)
{
var check = CreateSimpleCheck(accentBrush, 14);
Grid.SetColumn(check, 2);
catGrid.Children.Add(check);
}
catItem.Child = catGrid;
catItem.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
catItem.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
catItem.MouseLeftButtonUp += (_, _) =>
{
popup.IsOpen = false;
var c = _storage.Load(conversationId);
if (c != null)
{
c.Category = capturedKey;
var preset = Services.PresetService.GetByCategory(capturedKey);
if (preset != null)
c.SystemCommand = preset.SystemPrompt;
_storage.Save(c);
lock (_convLock)
{
if (_currentConversation?.Id == conversationId)
{
_currentConversation = ChatSession?.UpdateConversationMetadata(_activeTab, current =>
{
current.Category = capturedKey;
if (preset != null)
current.SystemCommand = preset.SystemPrompt;
}, _storage) ?? _currentConversation;
}
}
// 현재 대화의 카테고리가 변경되면 입력 안내 문구도 갱신
bool isCurrent;
lock (_convLock) { isCurrent = _currentConversation?.Id == conversationId; }
if (isCurrent && preset != null && !string.IsNullOrEmpty(preset.Placeholder))
{
_promptCardPlaceholder = preset.Placeholder;
UpdateWatermarkVisibility();
if (string.IsNullOrEmpty(InputBox.Text))
{
InputWatermark.Text = preset.Placeholder;
InputWatermark.Visibility = Visibility.Visible;
}
}
else if (isCurrent)
{
ClearPromptCardPlaceholder();
}
RefreshConversationList();
}
};
stack.Children.Add(catItem);
}
} // end showCategorySection
stack.Children.Add(CreateSeparator());
// 삭제
stack.Children.Add(CreateMenuItem("\uE74D", "이 대화 삭제", Brushes.IndianRed, () =>
{
var result = CustomMessageBox.Show("이 대화를 삭제하시겠습니까?", "대화 삭제",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
_storage.Delete(conversationId);
lock (_convLock)
{
if (_currentConversation?.Id == conversationId)
{
_currentConversation = null;
MessagePanel.Children.Clear();
EmptyState.Visibility = Visibility.Visible;
UpdateChatTitle();
}
}
RefreshConversationList();
}));
container.Child = stack;
popup.Child = container;
popup.IsOpen = true;
}
// ─── 검색 ────────────────────────────────────────────────────────────
private void SidebarSearchTrigger_MouseEnter(object sender, MouseEventArgs e)
{
if (SidebarSearchTrigger != null)
SidebarSearchTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
if (SidebarSearchShortcutHint != null)
SidebarSearchShortcutHint.Visibility = Visibility.Visible;
}
private void SidebarSearchTrigger_MouseLeave(object sender, MouseEventArgs e)
{
if (SidebarSearchTrigger != null)
SidebarSearchTrigger.Background = Brushes.Transparent;
if (SidebarSearchShortcutHint != null)
SidebarSearchShortcutHint.Visibility = Visibility.Collapsed;
}
private void SidebarSearchTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
OpenSidebarSearch();
}
private void SidebarNewChatTrigger_MouseEnter(object sender, MouseEventArgs e)
{
if (SidebarNewChatTrigger != null)
SidebarNewChatTrigger.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
if (SidebarNewChatShortcutHint != null)
SidebarNewChatShortcutHint.Visibility = Visibility.Visible;
}
private void SidebarNewChatTrigger_MouseLeave(object sender, MouseEventArgs e)
{
if (SidebarNewChatTrigger != null)
SidebarNewChatTrigger.Background = Brushes.Transparent;
if (SidebarNewChatShortcutHint != null)
SidebarNewChatShortcutHint.Visibility = Visibility.Collapsed;
}
private void SidebarNewChatTrigger_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
BtnNewChat_Click(sender, new RoutedEventArgs());
}
private void BtnSidebarSearchClose_Click(object sender, RoutedEventArgs e)
{
CloseSidebarSearch(clearText: true);
}
private void OpenSidebarSearch()
{
if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null)
return;
SidebarSearchTrigger.Visibility = Visibility.Collapsed;
SidebarSearchEditor.Visibility = Visibility.Visible;
SidebarSearchEditor.Opacity = 0;
SidebarSearchEditorScale.ScaleX = 0.85;
var duration = TimeSpan.FromMilliseconds(160);
SidebarSearchEditor.BeginAnimation(OpacityProperty, new DoubleAnimation(0, 1, duration));
SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(0.85, 1, duration)
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
});
Dispatcher.BeginInvoke(new Action(() =>
{
SearchBox?.Focus();
SearchBox?.SelectAll();
}), DispatcherPriority.Background);
}
private void CloseSidebarSearch(bool clearText)
{
if (SidebarSearchEditor == null || SidebarSearchTrigger == null || SidebarSearchEditorScale == null)
return;
if (clearText && SearchBox != null && !string.IsNullOrEmpty(SearchBox.Text))
SearchBox.Text = "";
var duration = TimeSpan.FromMilliseconds(140);
var opacityAnim = new DoubleAnimation(1, 0, duration);
opacityAnim.Completed += (_, _) =>
{
SidebarSearchEditor.Visibility = Visibility.Collapsed;
SidebarSearchTrigger.Visibility = Visibility.Visible;
SidebarSearchTrigger.Background = Brushes.Transparent;
if (SidebarSearchShortcutHint != null)
SidebarSearchShortcutHint.Visibility = Visibility.Collapsed;
};
SidebarSearchEditor.BeginAnimation(OpacityProperty, opacityAnim);
SidebarSearchEditorScale.BeginAnimation(System.Windows.Media.ScaleTransform.ScaleXProperty, new DoubleAnimation(1, 0.85, duration)
{
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }
});
}
private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
RefreshConversationList();
}
private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { }
private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e)
{
_runningOnlyFilter = false;
UpdateConversationRunningFilterUi();
PersistConversationListPreferences();
RefreshConversationList();
}
private void BtnQuickRunningFilter_Click(object sender, RoutedEventArgs e)
=> BtnRunningOnlyFilter_Click(sender, e);
private void BtnQuickHotSort_Click(object sender, RoutedEventArgs e)
{
_sortConversationsByRecent = false;
UpdateConversationSortUi();
PersistConversationListPreferences();
RefreshConversationList();
}
private void BtnConversationSort_Click(object sender, RoutedEventArgs e)
{
_sortConversationsByRecent = !_sortConversationsByRecent;
UpdateConversationSortUi();
PersistConversationListPreferences();
RefreshConversationList();
}
private void UpdateConversationFailureFilterUi()
{
_failedOnlyFilter = false;
UpdateSidebarModeMenu();
}
private void UpdateConversationRunningFilterUi()
{
if (BtnRunningOnlyFilter == null || RunningOnlyFilterLabel == null)
return;
BtnRunningOnlyFilter.Background = _runningOnlyFilter
? BrushFromHex("#DBEAFE")
: Brushes.Transparent;
BtnRunningOnlyFilter.BorderBrush = _runningOnlyFilter
? BrushFromHex("#93C5FD")
: Brushes.Transparent;
BtnRunningOnlyFilter.BorderThickness = _runningOnlyFilter
? new Thickness(1)
: new Thickness(0);
RunningOnlyFilterLabel.Foreground = _runningOnlyFilter
? BrushFromHex("#1D4ED8")
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
RunningOnlyFilterLabel.Text = _runningConversationCount > 0
? $"진행 {_runningConversationCount}"
: "진행";
BtnRunningOnlyFilter.ToolTip = _runningOnlyFilter
? "실행 중인 대화만 표시 중"
: _runningConversationCount > 0
? $"현재 실행 중인 대화 {_runningConversationCount}개 보기"
: "현재 실행 중인 대화만 보기";
UpdateSidebarModeMenu();
}
private void UpdateConversationSortUi()
{
if (BtnConversationSort == null || ConversationSortLabel == null)
return;
ConversationSortLabel.Text = _sortConversationsByRecent ? "최근" : "활동";
BtnConversationSort.Background = _sortConversationsByRecent
? BrushFromHex("#EFF6FF")
: BrushFromHex("#F8FAFC");
BtnConversationSort.BorderBrush = _sortConversationsByRecent
? BrushFromHex("#93C5FD")
: BrushFromHex("#E2E8F0");
BtnConversationSort.BorderThickness = new Thickness(1);
ConversationSortLabel.Foreground = _sortConversationsByRecent
? BrushFromHex("#1D4ED8")
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
BtnConversationSort.ToolTip = _sortConversationsByRecent
? "최신 업데이트 순으로 보는 중"
: "에이전트 활동량과 실패를 우선으로 보는 중";
}
private static string FormatDate(DateTime dt)
{
var diff = DateTime.Now - dt;
if (diff.TotalMinutes < 1) return "방금 전";
if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전";
if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전";
return dt.ToString("MM/dd");
}
private void UpdateChatTitle()
{
lock (_convLock)
{
ChatTitle.Text = _currentConversation?.Title ?? "";
}
}
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
private const int TimelineRenderPageSize = 180;
private string? _lastRenderedConversationId;
private int _timelineRenderLimit = TimelineRenderPageSize;
private void RenderMessages(bool preserveViewport = false)
{
var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0;
var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0;
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
var visibleMessages = conv?.Messages?.Where(msg => msg.Role != "system").ToList() ?? new List<ChatMessage>();
var visibleEvents = (conv?.ShowExecutionHistory ?? true)
? conv?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>()
: new List<ChatExecutionEvent>();
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
{
EmptyState.Visibility = Visibility.Visible;
return;
}
if (!string.Equals(_lastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
{
_lastRenderedConversationId = conv.Id;
_timelineRenderLimit = TimelineRenderPageSize;
}
EmptyState.Visibility = Visibility.Collapsed;
var timeline = new List<(DateTime Timestamp, int Order, Action Render)>();
foreach (var msg in visibleMessages)
timeline.Add((msg.Timestamp, 0, () => AddMessageBubble(msg.Role, msg.Content, animate: false, message: msg)));
foreach (var executionEvent in visibleEvents)
{
var restoredEvent = ToAgentEvent(executionEvent);
timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
}
var orderedTimeline = timeline.OrderBy(x => x.Timestamp).ThenBy(x => x.Order).ToList();
var hiddenCount = Math.Max(0, orderedTimeline.Count - _timelineRenderLimit);
if (hiddenCount > 0)
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
foreach (var item in orderedTimeline.Skip(hiddenCount))
item.Render();
if (!preserveViewport)
{
_ = Dispatcher.InvokeAsync(() =>
{
if (MessageScroll != null)
MessageScroll.ScrollToEnd();
}, DispatcherPriority.Background);
return;
}
_ = Dispatcher.InvokeAsync(() =>
{
if (MessageScroll == null)
return;
var newScrollableHeight = MessageScroll.ScrollableHeight;
var delta = newScrollableHeight - previousScrollableHeight;
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
MessageScroll.ScrollToVerticalOffset(targetOffset);
}, DispatcherPriority.Background);
}
private Border CreateTimelineLoadMoreCard(int hiddenCount)
{
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155");
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B");
var loadMoreBtn = new Button
{
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 5, 10, 5),
Cursor = System.Windows.Input.Cursors.Hand,
Foreground = primaryText,
HorizontalAlignment = HorizontalAlignment.Center,
};
loadMoreBtn.Template = BuildMinimalIconButtonTemplate();
loadMoreBtn.Content = new StackPanel
{
Orientation = Orientation.Horizontal,
Children =
{
new TextBlock
{
Text = "\uE70D",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"이전 대화 {hiddenCount:N0}개 더 보기",
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
}
}
};
loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg;
loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent;
loadMoreBtn.Click += (_, _) =>
{
_timelineRenderLimit += TimelineRenderPageSize;
RenderMessages(preserveViewport: true);
};
return new Border
{
CornerRadius = new CornerRadius(16),
Margin = new Thickness(0, 2, 0, 12),
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Center,
Child = loadMoreBtn,
};
}
private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent)
{
var parsedType = Enum.TryParse<AgentEventType>(executionEvent.Type, out var eventType)
? eventType
: AgentEventType.Thinking;
return new AgentEvent
{
Timestamp = executionEvent.Timestamp,
RunId = executionEvent.RunId,
Type = parsedType,
ToolName = executionEvent.ToolName,
Summary = executionEvent.Summary,
FilePath = executionEvent.FilePath,
Success = executionEvent.Success,
StepCurrent = executionEvent.StepCurrent,
StepTotal = executionEvent.StepTotal,
Steps = executionEvent.Steps,
ElapsedMs = executionEvent.ElapsedMs,
InputTokens = executionEvent.InputTokens,
OutputTokens = executionEvent.OutputTokens,
};
}
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
{
var isUser = role == "user";
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var userBubbleBg = hintBg;
var assistantBubbleBg = itemBg;
if (isUser)
{
// 사용자: 우측 정렬, 얇고 단정한 카드
var wrapper = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
MaxWidth = GetMessageMaxWidth(),
Margin = new Thickness(150, 3, 16, 3),
};
var bubble = new Border
{
Background = userBubbleBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(13, 9, 13, 9),
Child = new TextBlock
{
Text = content,
TextAlignment = TextAlignment.Left,
FontSize = 12.5,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
}
};
wrapper.Children.Add(bubble);
// 액션 버튼 바 (복사 + 편집, hover 시 표시)
var userActionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Opacity = 0,
Margin = new Thickness(0, 1, 0, 0),
};
var capturedUserContent = content;
var userBtnColor = secondaryText;
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
{
try { Clipboard.SetText(capturedUserContent); } catch { }
}));
userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor,
() => EnterEditMode(wrapper, capturedUserContent)));
// 타임스탬프 + 액션 바
var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) };
var timestamp = message?.Timestamp ?? DateTime.Now;
userBottomBar.Children.Add(new TextBlock
{
Text = timestamp.ToString("HH:mm"),
FontSize = 9, Opacity = 0.52,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
userBottomBar.Children.Add(userActionBar);
wrapper.Children.Add(userBottomBar);
wrapper.MouseEnter += (_, _) => ShowMessageActionBar(userActionBar);
wrapper.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(userActionBar);
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
// 우클릭 → 메시지 컨텍스트 메뉴
var userContent = content;
wrapper.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowMessageContextMenu(userContent, "user");
};
if (animate) ApplyMessageEntryAnimation(wrapper);
MessagePanel.Children.Add(wrapper);
}
else
{
// 어시스턴트: 좌측 정렬, 정돈된 카드
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = GetMessageMaxWidth(),
Margin = new Thickness(10, 3, 150, 3)
};
if (animate) ApplyMessageEntryAnimation(container);
// AI 에이전트 이름 + 아이콘
var (agentName, _, _) = GetAgentIdentity();
var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 0, 0, 2) };
var iconBlock = new TextBlock
{
Text = "\uE945",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 8,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
};
headerSp.Children.Add(iconBlock);
headerSp.Children.Add(new TextBlock
{
Text = agentName,
FontSize = 9,
FontWeight = FontWeights.Medium,
Foreground = secondaryText,
Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
});
container.Children.Add(headerSp);
var contentCard = new Border
{
Background = assistantBubbleBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(13, 10, 13, 10),
};
var contentStack = new StackPanel();
// 마크다운 렌더링 (파일 경로 강조 설정 연동)
var app = System.Windows.Application.Current as App;
MarkdownRenderer.EnableFilePathHighlight =
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
if (IsBranchContextMessage(content))
{
var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun();
var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3);
var branchCard = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 6),
};
var branchStack = new StackPanel();
branchStack.Children.Add(new TextBlock
{
Text = "분기 컨텍스트",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(0, 0, 0, 6),
});
var branchMd = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
branchStack.Children.Add(branchMd);
if (branchFiles.Count > 0)
{
var filesWrap = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
foreach (var path in branchFiles)
{
var fileButton = new Button
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
ToolTip = path,
Content = new TextBlock
{
Text = System.IO.Path.GetFileName(path),
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
}
};
var capturedPath = path;
fileButton.Click += (_, _) => OpenRunFilePath(capturedPath);
filesWrap.Children.Add(fileButton);
}
branchStack.Children.Add(filesWrap);
}
if (branchRun != null)
{
var actionsWrap = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
var followUpButton = new Button
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
Content = new TextBlock
{
Text = "후속 작업 큐에 넣기",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
}
};
var capturedBranchRun = branchRun;
followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun);
actionsWrap.Children.Add(followUpButton);
var timelineButton = new Button
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
Content = new TextBlock
{
Text = "관련 로그로 이동",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
}
};
timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId);
actionsWrap.Children.Add(timelineButton);
branchStack.Children.Add(actionsWrap);
}
branchCard.Child = branchStack;
contentStack.Children.Add(branchCard);
}
else
{
var mdPanel = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
contentStack.Children.Add(mdPanel);
}
contentCard.Child = contentStack;
container.Children.Add(contentCard);
// 액션 버튼 바 (복사 / 좋아요 / 싫어요)
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(2, 2, 0, 0),
Opacity = 0
};
var btnColor = secondaryText;
var capturedContent = content;
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
{
try { Clipboard.SetText(capturedContent); } catch { }
}));
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
AddLinkedFeedbackButtons(actionBar, btnColor, message);
// 타임스탬프
var aiTimestamp = message?.Timestamp ?? DateTime.Now;
actionBar.Children.Add(new TextBlock
{
Text = aiTimestamp.ToString("HH:mm"),
FontSize = 9, Opacity = 0.52,
Foreground = btnColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(6, 0, 0, 0),
});
container.Children.Add(actionBar);
container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
// 우클릭 → 메시지 컨텍스트 메뉴
var aiContent = content;
container.MouseRightButtonUp += (_, re) =>
{
re.Handled = true;
ShowMessageContextMenu(aiContent, "assistant");
};
MessagePanel.Children.Add(container);
}
}
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <summary>커스텀 체크/미선택 아이콘을 생성합니다. Path 도형 기반, 선택 시 스케일 바운스 애니메이션.</summary>
/// <summary>현재 테마의 체크 스타일을 반환합니다.</summary>
private string GetCheckStyle()
{
var theme = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant();
return theme switch
{
"dark" or "system" => "circle",
"light" => "roundrect",
_ => "circle",
};
}
private FrameworkElement CreateCheckIcon(bool isChecked, Brush? accentBrush = null)
{
var accent = accentBrush ?? TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
// 심플 V 체크 — 선택 시 컬러 V, 미선택 시 빈 공간
if (isChecked)
{
return CreateSimpleCheck(accent, 14);
}
// 미선택: 동일 크기 빈 공간 (정렬 유지)
return new System.Windows.Shapes.Rectangle
{
Width = 14, Height = 14,
Fill = Brushes.Transparent,
Margin = new Thickness(0, 0, 10, 0),
};
}
/// <summary>ScaleTransform 바운스/스케일 애니메이션 헬퍼.</summary>
private static void AnimateScale(FrameworkElement el, double from, double to, int ms, IEasingFunction ease)
{
if (el.RenderTransform is TransformGroup tg)
{
var st = tg.Children.OfType<ScaleTransform>().FirstOrDefault();
if (st != null)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
st.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
st.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
return;
}
}
if (el.RenderTransform is ScaleTransform scale)
{
var anim = new DoubleAnimation(from, to, TimeSpan.FromMilliseconds(ms)) { EasingFunction = ease };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, anim);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, anim);
}
}
/// <summary>마우스 오버 시 살짝 확대 + 복귀하는 호버 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 살짝 확대하는 호버 애니메이션.
/// 주의: 인접 요소(탭 버튼, 가로 나열 메뉴 등)에는 사용 금지 — 확대 시 이웃 요소를 가립니다.
/// 독립적 공간이 있는 버튼에만 적용하세요.
/// </summary>
private static void ApplyHoverScaleAnimation(FrameworkElement element, double hoverScale = 1.08)
{
// Loaded 이벤트에서 실행해야 XAML Style의 봉인된 Transform을 안전하게 교체 가능
void EnsureTransform()
{
element.RenderTransformOrigin = new Point(0.5, 0.5);
// 봉인(frozen)된 Transform이면 새로 생성하여 교체
if (element.RenderTransform is not ScaleTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new ScaleTransform(1, 1);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var grow = new DoubleAnimation(hoverScale, TimeSpan.FromMilliseconds(150))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, grow);
st.BeginAnimation(ScaleTransform.ScaleYProperty, grow);
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var st = (ScaleTransform)element.RenderTransform;
var shrink = new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } };
st.BeginAnimation(ScaleTransform.ScaleXProperty, shrink);
st.BeginAnimation(ScaleTransform.ScaleYProperty, shrink);
};
}
/// <summary>마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션을 적용합니다.</summary>
/// <summary>
/// 마우스 오버 시 텍스트가 살짝 튀어오르는 바운스 애니메이션.
/// Scale과 달리 크기가 변하지 않아 인접 요소를 가리지 않습니다.
/// </summary>
private static void ApplyHoverBounceAnimation(FrameworkElement element, double bounceY = -2.5)
{
void EnsureTransform()
{
if (element.RenderTransform is not TranslateTransform || element.RenderTransform.IsFrozen)
element.RenderTransform = new TranslateTransform(0, 0);
}
element.Loaded += (_, _) => EnsureTransform();
element.MouseEnter += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(bounceY, TimeSpan.FromMilliseconds(200))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
};
element.MouseLeave += (_, _) =>
{
EnsureTransform();
var tt = (TranslateTransform)element.RenderTransform;
tt.BeginAnimation(TranslateTransform.YProperty,
new DoubleAnimation(0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 10 } });
};
}
/// <summary>심플한 V 체크 아이콘을 생성합니다 (디자인 통일용).</summary>
private static FrameworkElement CreateSimpleCheck(Brush color, double size = 14)
{
return new System.Windows.Shapes.Path
{
Data = Geometry.Parse($"M {size * 0.15} {size * 0.5} L {size * 0.4} {size * 0.75} L {size * 0.85} {size * 0.28}"),
Stroke = color,
StrokeThickness = 2,
StrokeStartLineCap = PenLineCap.Round,
StrokeEndLineCap = PenLineCap.Round,
StrokeLineJoin = PenLineJoin.Round,
Width = size,
Height = size,
Margin = new Thickness(0, 0, 10, 0),
VerticalAlignment = VerticalAlignment.Center,
};
}
/// <summary>팝업 메뉴 항목에 호버 배경색 + 미세 확대 효과를 적용합니다.</summary>
private static void ApplyMenuItemHover(Border item)
{
var originalBg = item.Background?.Clone() ?? Brushes.Transparent;
if (originalBg.CanFreeze) originalBg.Freeze();
item.RenderTransformOrigin = new Point(0.5, 0.5);
item.RenderTransform = new ScaleTransform(1, 1);
item.MouseEnter += (s, _) =>
{
if (s is Border b)
{
// 원래 배경이 투명이면 반투명 흰색, 아니면 밝기 변경
if (originalBg is SolidColorBrush scb && scb.Color.A > 0x20)
b.Opacity = 0.85;
else
b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
}
var st = item.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.02, TimeSpan.FromMilliseconds(120)));
};
item.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Opacity = 1.0;
b.Background = originalBg;
}
var st = item.RenderTransform as ScaleTransform;
st?.BeginAnimation(ScaleTransform.ScaleXProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
st?.BeginAnimation(ScaleTransform.ScaleYProperty, new DoubleAnimation(1.0, TimeSpan.FromMilliseconds(150)));
};
}
private Button CreateActionButton(string symbol, string tooltip, Brush foreground, Action onClick)
{
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var icon = new TextBlock
{
Text = symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10.5,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center
};
var label = new TextBlock
{
Text = tooltip,
FontSize = 10.5,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
};
var content = new StackPanel
{
Orientation = Orientation.Horizontal,
Children = { icon, label }
};
var btn = new Button
{
Content = content,
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
btn.Template = BuildMinimalIconButtonTemplate();
btn.MouseEnter += (_, _) =>
{
icon.Foreground = hoverBrush;
label.Foreground = hoverBrush;
btn.Background = hoverBg;
};
btn.MouseLeave += (_, _) =>
{
icon.Foreground = foreground;
label.Foreground = foreground;
btn.Background = Brushes.Transparent;
};
btn.Click += (_, _) => onClick();
ApplyHoverScaleAnimation(btn, 1.04);
return btn;
}
private void ShowMessageActionBar(StackPanel actionBar)
{
if (actionBar == null)
return;
actionBar.Opacity = 1;
}
private void HideMessageActionBarIfNotSelected(StackPanel actionBar)
{
if (actionBar == null)
return;
if (!ReferenceEquals(_selectedMessageActionBar, actionBar))
actionBar.Opacity = 0;
}
private void SelectMessageActionBar(StackPanel actionBar, Border? messageBorder = null)
{
if (_selectedMessageActionBar != null && !ReferenceEquals(_selectedMessageActionBar, actionBar))
_selectedMessageActionBar.Opacity = 0;
if (_selectedMessageBorder != null && !ReferenceEquals(_selectedMessageBorder, messageBorder))
ApplyMessageSelectionStyle(_selectedMessageBorder, false);
_selectedMessageActionBar = actionBar;
_selectedMessageActionBar.Opacity = 1;
_selectedMessageBorder = messageBorder;
if (_selectedMessageBorder != null)
ApplyMessageSelectionStyle(_selectedMessageBorder, true);
}
private void ApplyMessageSelectionStyle(Border border, bool selected)
{
if (border == null)
return;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var defaultBorder = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
border.BorderBrush = selected ? accent : defaultBorder;
border.BorderThickness = selected ? new Thickness(1.5) : new Thickness(1);
border.Effect = selected
? new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16,
ShadowDepth = 0,
Opacity = 0.10,
Color = Colors.Black,
}
: null;
}
private static ControlTemplate BuildMinimalIconButtonTemplate()
{
var template = new ControlTemplate(typeof(Button));
var border = new FrameworkElementFactory(typeof(Border));
border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty));
border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty));
border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty));
border.SetValue(Border.CornerRadiusProperty, new CornerRadius(8));
border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty));
var presenter = new FrameworkElementFactory(typeof(ContentPresenter));
presenter.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center);
presenter.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center);
border.AppendChild(presenter);
template.VisualTree = border;
return template;
}
/// <summary>좋아요/싫어요 토글 피드백 버튼 (상태 영구 저장)</summary>
private Button CreateFeedbackButton(string outline, string filled, string tooltip,
Brush normalColor, Brush activeColor, ChatMessage? message = null, string feedbackType = "",
Action? resetSibling = null, Action<Action>? registerReset = null)
{
var hoverBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var isActive = message?.Feedback == feedbackType;
var icon = new TextBlock
{
Text = isActive ? filled : outline,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = isActive ? activeColor : normalColor,
VerticalAlignment = VerticalAlignment.Center,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1)
};
var btn = new Button
{
Content = icon,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(0, 0, 4, 0),
ToolTip = tooltip
};
// 상대 버튼이 리셋할 수 있도록 등록
registerReset?.Invoke(() =>
{
isActive = false;
icon.Text = outline;
icon.Foreground = normalColor;
});
btn.MouseEnter += (_, _) => { if (!isActive) icon.Foreground = hoverBrush; };
btn.MouseLeave += (_, _) => { if (!isActive) icon.Foreground = normalColor; };
btn.Click += (_, _) =>
{
isActive = !isActive;
icon.Text = isActive ? filled : outline;
icon.Foreground = isActive ? activeColor : normalColor;
// 상호 배타: 활성화 시 반대쪽 리셋
if (isActive) resetSibling?.Invoke();
// 피드백 상태 저장
if (message != null)
{
try
{
var feedback = isActive ? feedbackType : null;
var session = ChatSession;
if (session != null)
{
lock (_convLock)
{
session.UpdateMessageFeedback(_activeTab, message, feedback, _storage);
_currentConversation = session.CurrentConversation;
}
}
else
{
message.Feedback = feedback;
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null) _storage.Save(conv);
}
}
catch { }
}
// 바운스 애니메이션
var scale = (ScaleTransform)icon.RenderTransform;
var bounce = new DoubleAnimation(1.3, 1.0, TimeSpan.FromMilliseconds(250))
{ EasingFunction = new ElasticEase { EasingMode = EasingMode.EaseOut, Oscillations = 1, Springiness = 5 } };
scale.BeginAnimation(ScaleTransform.ScaleXProperty, bounce);
scale.BeginAnimation(ScaleTransform.ScaleYProperty, bounce);
};
return btn;
}
/// <summary>좋아요/싫어요 버튼을 상호 배타로 연결하여 추가</summary>
private void AddLinkedFeedbackButtons(StackPanel actionBar, Brush btnColor, ChatMessage? message)
{
// resetSibling는 나중에 설정되므로 Action 래퍼로 간접 참조
Action? resetLikeAction = null;
Action? resetDislikeAction = null;
var likeBtn = CreateFeedbackButton("\uE8E1", "\uEB51", "좋아요", btnColor,
new SolidColorBrush(Color.FromRgb(0x38, 0xA1, 0x69)), message, "like",
resetSibling: () => resetDislikeAction?.Invoke(),
registerReset: reset => resetLikeAction = reset);
var dislikeBtn = CreateFeedbackButton("\uE8E0", "\uEB50", "싫어요", btnColor,
new SolidColorBrush(Color.FromRgb(0xE5, 0x3E, 0x3E)), message, "dislike",
resetSibling: () => resetLikeAction?.Invoke(),
registerReset: reset => resetDislikeAction = reset);
actionBar.Children.Add(likeBtn);
actionBar.Children.Add(dislikeBtn);
}
// ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
private static void ApplyMessageEntryAnimation(FrameworkElement element)
{
element.Opacity = 0;
element.RenderTransform = new TranslateTransform(0, 16);
element.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
((TranslateTransform)element.RenderTransform).BeginAnimation(
TranslateTransform.YProperty,
new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400))
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
}
// ─── 메시지 편집 ──────────────────────────────────────────────────────
private bool _isEditing; // 편집 모드 중복 방지
private void EnterEditMode(StackPanel wrapper, string originalText)
{
if (_isStreaming || _isEditing) return;
_isEditing = true;
// wrapper 위치(인덱스) 기억
var idx = MessagePanel.Children.IndexOf(wrapper);
if (idx < 0) { _isEditing = false; return; }
// 편집 UI 생성
var editPanel = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Right,
MaxWidth = 540,
Margin = wrapper.Margin,
};
var editBox = new TextBox
{
Text = originalText,
FontSize = 13.5,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.DarkGray,
CaretBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
BorderBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
BorderThickness = new Thickness(1.5),
Padding = new Thickness(14, 10, 14, 10),
TextWrapping = TextWrapping.Wrap,
AcceptsReturn = false,
MaxHeight = 200,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
};
// 둥근 모서리
var editBorder = new Border
{
CornerRadius = new CornerRadius(14),
Child = editBox,
ClipToBounds = true,
};
editPanel.Children.Add(editBorder);
// 버튼 바
var btnBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 6, 0, 0),
};
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
// 취소 버튼
var cancelBtn = new Button
{
Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush },
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Cursor = Cursors.Hand,
Padding = new Thickness(12, 5, 12, 5),
Margin = new Thickness(0, 0, 6, 0),
};
cancelBtn.Click += (_, _) =>
{
_isEditing = false;
if (idx >= 0 && idx < MessagePanel.Children.Count)
MessagePanel.Children[idx] = wrapper; // 원래 버블 복원
};
btnBar.Children.Add(cancelBtn);
// 전송 버튼
var sendBtn = new Button
{
Cursor = Cursors.Hand,
Padding = new Thickness(0),
};
sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
"</Border></ControlTemplate>");
sendBtn.Click += (_, _) =>
{
var newText = editBox.Text.Trim();
if (!string.IsNullOrEmpty(newText))
_ = SubmitEditAsync(idx, newText);
};
btnBar.Children.Add(sendBtn);
editPanel.Children.Add(btnBar);
// 기존 wrapper → editPanel 교체
MessagePanel.Children[idx] = editPanel;
// Enter 키로도 전송
editBox.KeyDown += (_, ke) =>
{
if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None)
{
ke.Handled = true;
var newText = editBox.Text.Trim();
if (!string.IsNullOrEmpty(newText))
_ = SubmitEditAsync(idx, newText);
}
if (ke.Key == Key.Escape)
{
ke.Handled = true;
_isEditing = false;
if (idx >= 0 && idx < MessagePanel.Children.Count)
MessagePanel.Children[idx] = wrapper;
}
};
editBox.Focus();
editBox.SelectAll();
}
private async Task SubmitEditAsync(int bubbleIndex, string newText)
{
_isEditing = false;
if (_isStreaming) return;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null) return;
conv = _currentConversation;
}
// bubbleIndex에 해당하는 user 메시지 찾기
// UI의 children 중 user 메시지가 아닌 것(system)은 스킵됨
// 데이터 모델에서 해당 위치의 user 메시지 찾기
int userMsgIdx = -1;
int uiIdx = 0;
lock (_convLock)
{
for (int i = 0; i < conv.Messages.Count; i++)
{
if (conv.Messages[i].Role == "system") continue;
if (uiIdx == bubbleIndex)
{
userMsgIdx = i;
break;
}
uiIdx++;
}
}
if (userMsgIdx < 0) return;
// 데이터 모델에서 편집된 메시지 이후 모두 제거
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.UpdateUserMessageAndTrim(_activeTab, userMsgIdx, newText, _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages[userMsgIdx].Content = newText;
while (conv.Messages.Count > userMsgIdx + 1)
conv.Messages.RemoveAt(conv.Messages.Count - 1);
}
}
// UI에서 편집된 버블 이후 모두 제거
while (MessagePanel.Children.Count > bubbleIndex + 1)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 편집된 메시지를 새 버블로 교체
MessagePanel.Children.RemoveAt(bubbleIndex);
AddMessageBubble("user", newText, animate: false);
// 마지막 위치에 삽입되도록 조정 (AddMessageBubble은 끝에 추가됨)
// bubbleIndex가 끝이 아니면 이동 — 이 경우 이후가 다 제거되었으므로 끝에 추가됨
// AI 재응답
await SendRegenerateAsync(conv);
try { if (ChatSession == null) _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
RefreshConversationList();
}
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
private void StopAiIconPulse()
{
if (_aiIconPulseStopped || _activeAiIcon == null) return;
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
_activeAiIcon.Opacity = 1.0;
_activeAiIcon = null;
_aiIconPulseStopped = true;
}
private void CursorTimer_Tick(object? sender, EventArgs e)
{
_cursorVisible = !_cursorVisible;
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
if (_activeStreamText != null && _displayedLength > 0)
{
var displayed = _cachedStreamContent.Length > 0
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
: "";
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
}
}
private void ElapsedTimer_Tick(object? sender, EventArgs e)
{
var elapsed = DateTime.UtcNow - _streamStartTime;
var sec = (int)elapsed.TotalSeconds;
if (_elapsedLabel != null)
_elapsedLabel.Text = $"{sec}s";
// 하단 상태바 시간 갱신
if (StatusElapsed != null)
StatusElapsed.Text = $"{sec}초";
}
private void TypingTimer_Tick(object? sender, EventArgs e)
{
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
var targetLen = _cachedStreamContent.Length;
if (_displayedLength >= targetLen) return;
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
var pending = targetLen - _displayedLength;
int step;
if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
_displayedLength += step;
var displayed = _cachedStreamContent[.._displayedLength];
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
// 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
if (!_userScrolled)
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
}
// ─── 전송 ──────────────────────────────────────────────────────────────
public void SendInitialMessage(string message)
{
StartNewConversation();
InputBox.Text = message;
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
}
private void StartNewConversation()
{
// 현재 대화가 있으면 저장 후 새 대화 시작
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.SaveCurrentConversation(_storage, _activeTab);
session.ClearCurrentConversation(_activeTab);
_currentConversation = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
SyncTabConversationIdsFromSession();
}
else
{
if (_currentConversation != null && _currentConversation.Messages.Count > 0)
try { _storage.Save(_currentConversation); } catch { }
_currentConversation = ChatSession?.CreateFreshConversation(_activeTab, _settings)
?? new ChatConversation { Tab = _activeTab };
}
}
// 탭 기억 초기화 (새 대화이므로)
_tabConversationId[_activeTab] = null;
SyncTabConversationIdsToSession();
MessagePanel.Children.Clear();
EmptyState.Visibility = Visibility.Visible;
_attachedFiles.Clear();
RefreshAttachedFilesUI();
UpdateChatTitle();
RefreshConversationList();
UpdateFolderBar();
RefreshDraftQueueUi();
if (_activeTab == "Cowork") BuildBottomBar();
}
/// <summary>설정에 저장된 탭별 마지막 대화 ID를 복원하고, 현재 탭의 대화를 로드합니다.</summary>
private void RestoreLastConversations()
{
var session = ChatSession;
if (session == null)
return;
_activeTab = NormalizeTabName(session.ActiveTab);
if (string.Equals(_activeTab, "Chat", StringComparison.OrdinalIgnoreCase))
TabChat.IsChecked = true;
else if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase))
TabCowork.IsChecked = true;
else if (TabCode.IsEnabled)
TabCode.IsChecked = true;
SyncTabConversationIdsFromSession();
var hasRememberedConversation = !string.IsNullOrEmpty(session.GetConversationId(_activeTab));
if (!hasRememberedConversation)
{
var latestMeta = _storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), _activeTab, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(c => c.Pinned)
.ThenByDescending(c => c.UpdatedAt)
.FirstOrDefault();
if (latestMeta != null)
{
session.RememberConversation(_activeTab, latestMeta.Id);
SyncTabConversationIdsFromSession();
hasRememberedConversation = true;
}
}
if (hasRememberedConversation)
{
var conv = session.LoadOrCreateConversation(_activeTab, _storage, _settings);
lock (_convLock) _currentConversation = conv;
MessagePanel.Children.Clear();
RenderMessages();
EmptyState.Visibility = conv.Messages.Count > 0 ? Visibility.Collapsed : Visibility.Visible;
UpdateChatTitle();
UpdateFolderBar();
LoadConversationSettings();
SyncAppStateWithCurrentConversation();
RefreshConversationList();
RefreshDraftQueueUi();
SaveLastConversations();
}
}
/// <summary>현재 _tabConversationId를 설정에 저장합니다.</summary>
private void SaveLastConversations()
{
var session = ChatSession;
if (session != null)
{
SyncTabConversationIdsToSession();
session.ActiveTab = _activeTab;
session.Save(_settings);
SyncTabConversationIdsFromSession();
return;
}
var dict = new Dictionary<string, string>();
foreach (var kv in _tabConversationId)
{
if (!string.IsNullOrEmpty(kv.Value))
dict[kv.Key] = kv.Value;
}
_settings.Settings.Llm.LastConversationIds = dict;
try { _settings.Save(); } catch { }
}
private void BtnSend_Click(object sender, RoutedEventArgs e)
=> QueueComposerDraft(priority: "now", explicitKind: null, startImmediatelyWhenIdle: true);
private void InputBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (TryHandleSlashNavigationKey(e))
return;
// Ctrl+V: 클립보드 이미지 붙여넣기
if (e.Key == Key.V && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
{
if (TryPasteClipboardImage())
{
e.Handled = true;
return;
}
// 이미지가 아니면 기본 텍스트 붙여넣기로 위임
}
if (e.Key == Key.Enter)
{
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
// Shift+Enter → 줄바꿈 (AcceptsReturn=true이므로 기본 동작으로 위임)
return;
}
// 슬래시 팝업이 열려 있으면 선택된 항목 실행
if (SlashPopup.IsOpen && _slashPalette.SelectedIndex >= 0)
{
e.Handled = true;
ExecuteSlashSelectedItem();
return;
}
// /help 직접 입력 시 도움말 창 표시
if (InputBox.Text.Trim().Equals("/help", StringComparison.OrdinalIgnoreCase))
{
e.Handled = true;
InputBox.Text = "";
SlashPopup.IsOpen = false;
ShowSlashHelpWindow();
return;
}
// Ctrl+Enter → 즉시 실행 대기열, Enter → 다음 대기열
e.Handled = true;
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
{
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
return;
}
QueueComposerDraft(priority: "next", explicitKind: null, startImmediatelyWhenIdle: true);
}
}
/// <summary>클립보드에 이미지가 있으면 붙여넣기. 성공 시 true.</summary>
private bool TryPasteClipboardImage()
{
if (!_settings.Settings.Llm.EnableImageInput) return false;
if (!Clipboard.ContainsImage()) return false;
try
{
var img = Clipboard.GetImage();
if (img == null) return false;
// base64 인코딩
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(img));
using var ms = new System.IO.MemoryStream();
encoder.Save(ms);
var bytes = ms.ToArray();
// 크기 제한 확인
var maxKb = _settings.Settings.Llm.MaxImageSizeKb;
if (maxKb <= 0) maxKb = 5120;
if (bytes.Length > maxKb * 1024)
{
CustomMessageBox.Show($"이미지가 너무 큽니다 ({bytes.Length / 1024}KB, 최대 {maxKb}KB).",
"이미지 크기 초과", MessageBoxButton.OK, MessageBoxImage.Warning);
return true; // 처리됨 (에러이지만 이미지였음)
}
var base64 = Convert.ToBase64String(bytes);
var attachment = new ImageAttachment
{
Base64 = base64,
MimeType = "image/png",
FileName = $"clipboard_{DateTime.Now:HHmmss}.png",
};
_pendingImages.Add(attachment);
AddImagePreview(attachment, img);
return true;
}
catch (Exception ex)
{
Services.LogService.Debug($"클립보드 이미지 붙여넣기 실패: {ex.Message}");
return false;
}
}
/// <summary>이미지 미리보기 UI 추가.</summary>
private void AddImagePreview(ImageAttachment attachment, System.Windows.Media.Imaging.BitmapSource? thumbnail = null)
{
var secondaryBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hintBg = TryFindResource("HintBackground") as Brush ?? Brushes.LightGray;
var chip = new Border
{
Background = hintBg,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(4),
Margin = new Thickness(0, 0, 4, 4),
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
// 썸네일 이미지
if (thumbnail != null)
{
sp.Children.Add(new System.Windows.Controls.Image
{
Source = thumbnail,
MaxHeight = 48, MaxWidth = 64,
Stretch = Stretch.Uniform,
Margin = new Thickness(0, 0, 4, 0),
});
}
else
{
// base64에서 썸네일 생성
sp.Children.Add(new TextBlock
{
Text = "\uE8B9", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16,
Foreground = secondaryBrush, VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 4, 0),
});
}
sp.Children.Add(new TextBlock
{
Text = attachment.FileName, FontSize = 10, Foreground = secondaryBrush,
VerticalAlignment = VerticalAlignment.Center, MaxWidth = 100,
TextTrimming = TextTrimming.CharacterEllipsis,
});
var capturedAttachment = attachment;
var capturedChip = chip;
var removeBtn = new Button
{
Content = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8, Foreground = secondaryBrush },
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
};
removeBtn.Click += (_, _) =>
{
_pendingImages.Remove(capturedAttachment);
AttachedFilesPanel.Items.Remove(capturedChip);
if (_pendingImages.Count == 0 && _attachedFiles.Count == 0)
AttachedFilesPanel.Visibility = Visibility.Collapsed;
};
sp.Children.Add(removeBtn);
chip.Child = sp;
AttachedFilesPanel.Items.Add(chip);
AttachedFilesPanel.Visibility = Visibility.Visible;
}
// ─── 슬래시 명령어 ────────────────────────────────────────────────────
// ── 슬래시 명령어 팝업 상태 ──
private readonly SlashPaletteState _slashPalette = new();
private readonly Dictionary<int, FrameworkElement> _slashVisibleItemByAbsoluteIndex = new();
private readonly List<int> _slashVisibleAbsoluteOrder = new();
// ── 슬래시 명령어 (탭별 분류) ──
private void InputBox_TextChanged(object sender, TextChangedEventArgs e)
{
UpdateWatermarkVisibility();
RefreshContextUsageVisual();
var text = InputBox.Text;
// 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제
if (_slashPalette.ActiveCommand != null && text.StartsWith("/"))
HideSlashChip(restoreText: false);
if (text.StartsWith("/") && !text.Contains(' '))
{
// 탭별 필터링: Chat → "all"만, Cowork/Code → "all" + "dev"
bool isDev = _activeTab is "Cowork" or "Code";
// 내장 슬래시 명령어 매칭 (탭 필터)
var matches = SlashCommandCatalog.MatchBuiltinCommands(text, isDev);
// 스킬 슬래시 명령어 매칭 (탭별 필터)
if (_settings.Settings.Llm.EnableSkillSystem)
{
var skillMatches = SkillService.MatchSlashCommand(text)
.Where(s => s.IsVisibleInTab(_activeTab))
.Select(s => (Cmd: "/" + s.Name,
Label: BuildSlashSkillLabel(s),
IsSkill: true, Available: s.IsAvailable));
foreach (var sm in skillMatches)
matches.Add((sm.Cmd, sm.Label, sm.IsSkill));
}
if (matches.Count > 0)
{
_slashPalette.Matches = matches;
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(matches);
RenderSlashPage();
SlashPopup.IsOpen = true;
RefreshDraftQueueUi();
return;
}
}
SlashPopup.IsOpen = false;
RefreshDraftQueueUi();
}
private static string BuildSlashSkillLabel(SkillDefinition skill)
{
var badge = string.Equals(skill.ExecutionContext, "fork", StringComparison.OrdinalIgnoreCase)
? "[FORK]"
: "[DIRECT]";
var baseLabel = $"{badge} {skill.Label}";
return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}";
}
private bool GetSlashSectionExpanded(string sectionKey, bool defaultValue = true)
{
var map = _settings.Settings.Llm.SlashPaletteSections;
if (map != null && map.TryGetValue(sectionKey, out var expanded))
return expanded;
return defaultValue;
}
private void SetSlashSectionExpanded(string sectionKey, bool expanded)
{
var map = _settings.Settings.Llm.SlashPaletteSections ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
map[sectionKey] = expanded;
try { _settings.Save(); } catch { }
}
private bool AreAllSlashSectionsExpanded()
{
var commandsExpanded = GetSlashSectionExpanded("slash_commands", true);
var skillsExpanded = GetSlashSectionExpanded("slash_skills", true);
return commandsExpanded && skillsExpanded;
}
private void BtnSlashToggleGroups_Click(object sender, RoutedEventArgs e)
{
var expandAll = !AreAllSlashSectionsExpanded();
SetSlashSectionExpanded("slash_commands", expandAll);
SetSlashSectionExpanded("slash_skills", expandAll);
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
RenderSlashPage();
}
private void BtnSlashReset_Click(object sender, RoutedEventArgs e)
{
_settings.Settings.Llm.FavoriteSlashCommands.Clear();
_settings.Settings.Llm.RecentSlashCommands.Clear();
try { _settings.Save(); } catch { }
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
RenderSlashPage();
}
private Dictionary<string, int> BuildRecentSlashRankMap()
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var recent = _settings.Settings.Llm.RecentSlashCommands;
for (var i = 0; i < recent.Count; i++)
{
var key = recent[i]?.Trim();
if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key))
continue;
map[key] = i; // index 낮을수록 최근
}
return map;
}
private Dictionary<string, int> BuildFavoriteSlashRankMap()
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var fav = _settings.Settings.Llm.FavoriteSlashCommands;
var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30);
for (var i = 0; i < fav.Count; i++)
{
if (i >= maxFavorites)
break;
var key = fav[i]?.Trim();
if (string.IsNullOrWhiteSpace(key) || map.ContainsKey(key))
continue;
map[key] = i; // index 낮을수록 우선
}
return map;
}
private void RegisterRecentSlashCommand(string cmd)
{
if (string.IsNullOrWhiteSpace(cmd))
return;
var recent = _settings.Settings.Llm.RecentSlashCommands;
var maxRecent = Math.Clamp(_settings.Settings.Llm.MaxRecentSlashCommands, 5, 50);
recent.RemoveAll(x => string.Equals(x, cmd, StringComparison.OrdinalIgnoreCase));
recent.Insert(0, cmd);
if (recent.Count > maxRecent)
recent.RemoveRange(maxRecent, recent.Count - maxRecent);
try { _settings.Save(); } catch { }
}
private int GetFirstVisibleSlashIndex(IReadOnlyList<(string Cmd, string Label, bool IsSkill)> matches)
{
var commandExpanded = GetSlashSectionExpanded("slash_commands", true);
var skillExpanded = GetSlashSectionExpanded("slash_skills", true);
for (var i = 0; i < matches.Count; i++)
{
var visible = matches[i].IsSkill ? skillExpanded : commandExpanded;
if (visible)
return i;
}
return -1;
}
private bool IsSlashItemVisibleByIndex(int index)
{
if (index < 0 || index >= _slashPalette.Matches.Count)
return false;
var item = _slashPalette.Matches[index];
return item.IsSkill
? GetSlashSectionExpanded("slash_skills", true)
: GetSlashSectionExpanded("slash_commands", true);
}
private IReadOnlyList<int> GetVisibleSlashOrderedIndices() => _slashVisibleAbsoluteOrder;
/// <summary>현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다.</summary>
private void RenderSlashPage()
{
SlashItems.Items.Clear();
_slashVisibleItemByAbsoluteIndex.Clear();
_slashVisibleAbsoluteOrder.Clear();
var total = _slashPalette.Matches.Count;
var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill);
var totalCommands = total - totalSkills;
var favoriteRank = BuildFavoriteSlashRankMap();
var recentRank = BuildRecentSlashRankMap();
var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant();
SlashPopupTitle.Text = "명령 및 스킬";
SlashPopupHint.Text = expressionLevel switch
{
"simple" => $"명령 {totalCommands} · 스킬 {totalSkills}",
"rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행 · 방향키 이동",
_ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · Enter 실행",
};
var commandsExpanded = GetSlashSectionExpanded("slash_commands", true);
var skillsExpanded = GetSlashSectionExpanded("slash_skills", true);
if (SlashToggleGroupsLabel != null)
SlashToggleGroupsLabel.Text = (commandsExpanded && skillsExpanded) ? "전체 접기" : "전체 펼치기";
Border CreateSlashSectionHeader(string key, string title, int count, bool expanded)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
var header = new Border
{
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(0, 4, 0, 2),
Cursor = Cursors.Hand,
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.Children.Add(new TextBlock
{
Text = expanded ? "\uE70D" : "\uE76C",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
});
var titleText = new TextBlock
{
Text = $"{title} {count}",
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(titleText, 1);
grid.Children.Add(titleText);
var metaText = new TextBlock
{
Text = expanded ? "접기" : "펼치기",
FontSize = 9.5,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(metaText, 2);
grid.Children.Add(metaText);
header.Child = grid;
header.MouseEnter += (_, _) => header.Background = hoverBrushItem;
header.MouseLeave += (_, _) => header.Background = Brushes.Transparent;
header.MouseLeftButtonDown += (_, _) =>
{
SetSlashSectionExpanded(key, !expanded);
_slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(_slashPalette.Matches);
RenderSlashPage();
};
return header;
}
void AddSlashItem(int i)
{
var (cmd, label, isSkill) = _slashPalette.Matches[i];
var isFavorite = favoriteRank.ContainsKey(cmd);
var isRecent = recentRank.ContainsKey(cmd);
var capturedCmd = cmd;
var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null;
var skillAvailable = skillDef?.IsAvailable ?? true;
var absoluteIndex = i;
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var item = new Border
{
Background = Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(8, 9, 8, 9),
Margin = new Thickness(0, 0, 0, 0),
Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow,
Opacity = skillAvailable ? 1.0 : 0.5,
};
var itemGrid = new Grid();
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var leftStack = new StackPanel();
var titleRow = new StackPanel { Orientation = Orientation.Horizontal };
titleRow.Children.Add(new TextBlock
{
Text = isSkill ? "\uE768" : "\uE9CE",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = skillAvailable ? accent : secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
titleRow.Children.Add(new TextBlock
{
Text = cmd,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = skillAvailable ? primaryText : secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
if (isFavorite)
{
titleRow.Children.Add(new Border
{
Background = BrushFromHex("#FEF3C7"),
BorderBrush = BrushFromHex("#F59E0B"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(5, 0, 5, 0),
Margin = new Thickness(6, 0, 0, 0),
Child = new TextBlock
{
Text = "핀",
FontSize = 9.5,
Foreground = BrushFromHex("#92400E"),
}
});
}
if (isRecent)
{
titleRow.Children.Add(new Border
{
Background = BrushFromHex("#EEF2FF"),
BorderBrush = BrushFromHex("#C7D2FE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(5, 0, 5, 0),
Margin = new Thickness(6, 0, 0, 0),
Child = new TextBlock
{
Text = "최근",
FontSize = 9.5,
Foreground = BrushFromHex("#3730A3"),
}
});
}
leftStack.Children.Add(titleRow);
leftStack.Children.Add(new TextBlock
{
Text = label,
FontSize = 11,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(20, 2, 0, 0),
TextTrimming = TextTrimming.CharacterEllipsis,
});
Grid.SetColumn(leftStack, 0);
itemGrid.Children.Add(leftStack);
var pinToggle = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(6, 4, 6, 4),
Margin = new Thickness(6, 0, 0, 0),
Cursor = Cursors.Hand,
Child = new TextBlock
{
Text = isFavorite ? "\uE77A" : "\uE718",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = isFavorite ? BrushFromHex("#B45309") : secondaryText,
VerticalAlignment = VerticalAlignment.Center,
},
ToolTip = isFavorite ? "핀 해제" : "핀 고정",
};
pinToggle.MouseEnter += (_, _) => pinToggle.Background = hoverBrushItem;
pinToggle.MouseLeave += (_, _) => pinToggle.Background = Brushes.Transparent;
pinToggle.MouseLeftButtonDown += (s, e) =>
{
e.Handled = true;
ToggleSlashFavorite(capturedCmd);
};
Grid.SetColumn(pinToggle, 1);
itemGrid.Children.Add(pinToggle);
item.Child = itemGrid;
if (skillAvailable)
{
item.MouseEnter += (_, _) =>
{
_slashPalette.SelectedIndex = absoluteIndex;
UpdateSlashSelectionVisualState();
};
item.MouseLeave += (_, _) => UpdateSlashSelectionVisualState();
item.MouseLeftButtonDown += (_, _) =>
{
_slashPalette.SelectedIndex = absoluteIndex;
ExecuteSlashSelectedItem();
};
}
SlashItems.Items.Add(item);
_slashVisibleItemByAbsoluteIndex[absoluteIndex] = item;
_slashVisibleAbsoluteOrder.Add(absoluteIndex);
}
SlashItems.Items.Add(CreateSlashSectionHeader("slash_commands", "명령", totalCommands, commandsExpanded));
if (commandsExpanded)
{
var commandIndices = Enumerable.Range(0, total)
.Where(i => !_slashPalette.Matches[i].IsSkill)
.OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue)
.ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue)
.ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase);
foreach (var i in commandIndices)
{
AddSlashItem(i);
}
}
SlashItems.Items.Add(CreateSlashSectionHeader("slash_skills", "스킬", totalSkills, skillsExpanded));
if (skillsExpanded)
{
var skillIndices = Enumerable.Range(0, total)
.Where(i => _slashPalette.Matches[i].IsSkill)
.OrderBy(i => favoriteRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var favRank) ? favRank : int.MaxValue)
.ThenBy(i => recentRank.TryGetValue(_slashPalette.Matches[i].Cmd, out var rank) ? rank : int.MaxValue)
.ThenBy(i => _slashPalette.Matches[i].Cmd, StringComparer.OrdinalIgnoreCase);
foreach (var i in skillIndices)
{
AddSlashItem(i);
}
}
var visibleCommandCount = commandsExpanded ? totalCommands : 0;
var visibleSkillCount = skillsExpanded ? totalSkills : 0;
if (visibleCommandCount + visibleSkillCount == 0)
{
SlashPopupFooter.Text = "모든 그룹이 접혀 있습니다 · 우측 상단에서 전체 펼치기";
}
else
{
SlashPopupFooter.Text = $"Enter 실행 · ↑↓/PgUp/PgDn 이동 · Home/End · Esc 닫기 · 표시 {visibleCommandCount + visibleSkillCount}/{total}";
}
UpdateSlashSelectionVisualState();
EnsureSlashSelectionVisible();
}
/// <summary>슬래시 팝업 마우스 휠 스크롤 처리.</summary>
private void SlashPopup_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
e.Handled = true;
SlashPopup_ScrollByDelta(e.Delta);
}
private void MoveSlashSelection(int direction)
{
var visibleOrder = GetVisibleSlashOrderedIndices();
if (visibleOrder.Count == 0)
return;
var currentPosition = -1;
for (var i = 0; i < visibleOrder.Count; i++)
{
if (visibleOrder[i] != _slashPalette.SelectedIndex)
continue;
currentPosition = i;
break;
}
if (currentPosition < 0)
{
_slashPalette.SelectedIndex = visibleOrder[0];
return;
}
if (direction < 0 && currentPosition > 0)
_slashPalette.SelectedIndex = visibleOrder[currentPosition - 1];
else if (direction > 0 && currentPosition < visibleOrder.Count - 1)
_slashPalette.SelectedIndex = visibleOrder[currentPosition + 1];
}
private int? FindSlashIndexClosestToViewportTop()
{
if (SlashScrollViewer == null || _slashVisibleAbsoluteOrder.Count == 0)
return null;
var bestIndex = -1;
var bestDistance = double.MaxValue;
foreach (var absoluteIndex in _slashVisibleAbsoluteOrder)
{
if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(absoluteIndex, out var item))
continue;
try
{
var bounds = item.TransformToAncestor(SlashScrollViewer)
.TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight));
// 뷰포트 상단에 가장 가까운 가시 항목을 선택 기준으로 사용.
var distance = Math.Abs(bounds.Top);
if (distance < bestDistance && bounds.Bottom >= 0)
{
bestDistance = distance;
bestIndex = absoluteIndex;
}
}
catch
{
// 레이아웃 갱신 중 transform 예외는 무시.
}
}
return bestIndex >= 0 ? bestIndex : null;
}
/// <summary>슬래시 팝업을 Delta 방향으로 스크롤합니다.</summary>
private void SlashPopup_ScrollByDelta(int delta)
{
if (_slashPalette.Matches.Count == 0)
return;
if (GetVisibleSlashOrderedIndices().Count == 0)
{
if (SlashScrollViewer != null)
SlashScrollViewer.ScrollToVerticalOffset(Math.Max(0, SlashScrollViewer.VerticalOffset - delta / 3.0));
return;
}
// 터치패드/마우스 환경 모두에서 체감이 유사하도록 스크롤뷰도 함께 이동.
if (SlashScrollViewer != null)
{
var target = Math.Max(0, Math.Min(
SlashScrollViewer.ScrollableHeight,
SlashScrollViewer.VerticalOffset - (delta / 3.0)));
SlashScrollViewer.ScrollToVerticalOffset(target);
}
var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(delta) / 120.0));
var direction = delta > 0 ? -1 : 1;
for (var i = 0; i < steps; i++)
MoveSlashSelection(direction);
var viewportTopIndex = FindSlashIndexClosestToViewportTop();
if (viewportTopIndex.HasValue)
_slashPalette.SelectedIndex = viewportTopIndex.Value;
UpdateSlashSelectionVisualState();
EnsureSlashSelectionVisible();
}
/// <summary>키보드로 선택된 슬래시 아이템을 실행합니다.</summary>
private void ExecuteSlashSelectedItem()
{
var absoluteIdx = _slashPalette.SelectedIndex;
if (absoluteIdx < 0 || absoluteIdx >= _slashPalette.Matches.Count) return;
var (cmd, _, isSkill) = _slashPalette.Matches[absoluteIdx];
var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null;
var skillAvailable = skillDef?.IsAvailable ?? true;
if (!skillAvailable) return;
RegisterRecentSlashCommand(cmd);
SlashPopup.IsOpen = false;
_slashPalette.SelectedIndex = -1;
if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase))
{
InputBox.Text = "";
ShowSlashHelpWindow();
return;
}
ShowSlashChip(cmd);
InputBox.Focus();
}
private void EnsureSlashSelectionVisible()
{
if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0)
return;
if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(_slashPalette.SelectedIndex, out var item))
return;
if (!IsVisualDescendantOf(item, SlashScrollViewer))
return;
Rect bounds;
try
{
bounds = item.TransformToAncestor(SlashScrollViewer)
.TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight));
}
catch
{
// 렌더 트리 갱신 중에는 transform이 실패할 수 있어 조용히 무시.
return;
}
if (bounds.Top < 0)
SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + bounds.Top - 8);
else if (bounds.Bottom > SlashScrollViewer.ViewportHeight)
SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + (bounds.Bottom - SlashScrollViewer.ViewportHeight) + 8);
}
private static bool IsVisualDescendantOf(DependencyObject? child, DependencyObject? parent)
{
if (child == null || parent == null)
return false;
var current = child;
while (current != null)
{
if (ReferenceEquals(current, parent))
return true;
current = VisualTreeHelper.GetParent(current);
}
return false;
}
private void UpdateSlashSelectionVisualState()
{
if (_slashVisibleItemByAbsoluteIndex.Count == 0)
return;
var selectedIndex = _slashPalette.SelectedIndex;
var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
foreach (var (absoluteIndex, element) in _slashVisibleItemByAbsoluteIndex)
{
if (element is not Border border)
continue;
var selected = absoluteIndex == selectedIndex;
border.Background = selected ? hoverBrushItem : Brushes.Transparent;
border.BorderBrush = selected ? accent : borderBrush;
border.BorderThickness = selected ? new Thickness(2, 0, 0, 1) : new Thickness(0, 0, 0, 1);
}
}
/// <summary>슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다.</summary>
private void ToggleSlashFavorite(string cmd)
{
var favs = _settings.Settings.Llm.FavoriteSlashCommands;
var maxFavorites = Math.Clamp(_settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30);
var existing = favs.FirstOrDefault(f => f.Equals(cmd, StringComparison.OrdinalIgnoreCase));
if (existing != null)
favs.Remove(existing);
else
{
favs.Add(cmd);
if (favs.Count > maxFavorites)
favs.RemoveRange(maxFavorites, favs.Count - maxFavorites);
}
_settings.Save();
if (SlashPopup.IsOpen)
{
RenderSlashPage();
return;
}
// 팝업이 닫힌 경우에만 TextChanged 트리거
var currentText = InputBox.Text;
InputBox.TextChanged -= InputBox_TextChanged;
InputBox.Text = "";
InputBox.TextChanged += InputBox_TextChanged;
InputBox.Text = currentText;
}
/// <summary>슬래시 명령어 칩을 표시하고 InputBox를 비웁니다.</summary>
private void ShowSlashChip(string cmd)
{
_slashPalette.ActiveCommand = cmd;
SlashChipText.Text = cmd;
SlashCommandChip.Visibility = Visibility.Visible;
// 칩 너비 측정 후 InputBox 왼쪽 여백 조정
SlashCommandChip.UpdateLayout();
var chipRight = SlashCommandChip.Margin.Left + SlashCommandChip.ActualWidth + 6;
InputBox.Padding = new Thickness(chipRight, 10, 14, 10);
InputBox.Text = "";
}
/// <summary>슬래시 명령어 칩을 숨깁니다.</summary>
/// <param name="restoreText">true이면 InputBox에 명령어 텍스트를 복원합니다.</param>
private void HideSlashChip(bool restoreText = false)
{
if (_slashPalette.ActiveCommand == null) return;
var prev = _slashPalette.ActiveCommand;
_slashPalette.ActiveCommand = null;
SlashCommandChip.Visibility = Visibility.Collapsed;
InputBox.Padding = new Thickness(14, 10, 14, 10);
if (restoreText)
{
InputBox.Text = prev + " ";
InputBox.CaretIndex = InputBox.Text.Length;
}
}
/// <summary>슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다.</summary>
private (string? slashSystem, string userText) ParseSlashCommand(string input)
{
var trimmed = input.TrimStart();
if (trimmed.StartsWith("/"))
{
var firstSpace = trimmed.IndexOf(' ');
var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim();
if (SlashCommandCatalog.TryGetEntry(commandToken, out var entry))
{
// __HELP__는 특수 처리 (ParseSlashCommand에서는 무시)
if (entry.SystemPrompt == "__HELP__") return (null, input);
var rest = firstSpace >= 0 ? trimmed[(firstSpace + 1)..].Trim() : "";
return (entry.SystemPrompt, string.IsNullOrEmpty(rest) ? commandToken : rest);
}
}
// 스킬 명령어 매칭
var matchedSkill = SkillService.MatchSlashInvocation(input);
if (matchedSkill != null)
{
var slashCmd = "/" + matchedSkill.Name;
var rest = input[slashCmd.Length..].Trim();
var runtimePolicy = SkillService.BuildRuntimeDirective(matchedSkill);
var mergedPrompt = string.IsNullOrWhiteSpace(runtimePolicy)
? matchedSkill.SystemPrompt
: $"{matchedSkill.SystemPrompt}\n\n{runtimePolicy}";
return (mergedPrompt, string.IsNullOrEmpty(rest) ? matchedSkill.Label : rest);
}
return (null, input);
}
// ─── 드래그 앤 드롭 AI 액션 팝업 ─────────────────────────────────────
private static readonly Dictionary<string, List<(string Label, string Icon, string Prompt)>> DropActions = new(StringComparer.OrdinalIgnoreCase)
{
["code"] =
[
("코드 리뷰", "\uE943", "첨부된 코드를 리뷰해 주세요. 버그, 성능 이슈, 보안 취약점, 개선점을 찾아 구체적으로 제안하세요."),
("코드 설명", "\uE946", "첨부된 코드를 상세히 설명해 주세요. 주요 함수, 데이터 흐름, 설계 패턴을 포함하세요."),
("리팩토링 제안", "\uE70F", "첨부된 코드의 리팩토링 방안을 제안해 주세요. 가독성, 유지보수성, 성능을 고려하세요."),
("테스트 생성", "\uE9D5", "첨부된 코드에 대한 단위 테스트 코드를 생성해 주세요."),
],
["document"] =
[
("요약", "\uE8AB", "첨부된 문서를 핵심 포인트 위주로 간결하게 요약해 주세요."),
("분석", "\uE9D9", "첨부된 문서의 내용을 분석하고 주요 인사이트를 도출해 주세요."),
("번역", "\uE8C1", "첨부된 문서를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요."),
],
["data"] =
[
("데이터 분석", "\uE9D9", "첨부된 데이터를 분석해 주세요. 통계, 추세, 이상치를 찾아 보고하세요."),
("시각화 제안", "\uE9D9", "첨부된 데이터를 시각화할 최적의 차트 유형을 제안하고 chart_create로 생성해 주세요."),
("포맷 변환", "\uE8AB", "첨부된 데이터를 다른 형식으로 변환해 주세요. (CSV↔JSON↔Excel 등)"),
],
["image"] =
[
("이미지 설명", "\uE946", "첨부된 이미지를 자세히 설명해 주세요. 내용, 레이아웃, 텍스트를 분석하세요."),
("UI 리뷰", "\uE70F", "첨부된 UI 스크린샷을 리뷰해 주세요. UX 개선점, 접근성, 디자인 일관성을 평가하세요."),
],
};
private static readonly HashSet<string> CodeExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".cs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".cpp", ".c", ".h", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", ".sh", ".ps1", ".bat", ".cmd", ".sql", ".xaml", ".vue" };
private static readonly HashSet<string> DataExtensions = new(StringComparer.OrdinalIgnoreCase)
{ ".csv", ".json", ".xml", ".yaml", ".yml", ".tsv" };
// ImageExtensions는 이미지 첨부 영역(line ~1323)에서 정의됨 — 재사용
private Popup? _dropActionPopup;
private void ShowDropActionMenu(string[] files)
{
// 파일 유형 판별
var ext = System.IO.Path.GetExtension(files[0]).ToLowerInvariant();
string category;
if (CodeExtensions.Contains(ext)) category = "code";
else if (DataExtensions.Contains(ext)) category = "data";
else if (ImageExtensions.Contains(ext)) category = "image";
else category = "document";
var actions = DropActions.GetValueOrDefault(category) ?? DropActions["document"];
// 팝업 생성
_dropActionPopup?.SetValue(Popup.IsOpenProperty, false);
var panel = new StackPanel();
// 헤더
var header = new TextBlock
{
Text = $"📎 {System.IO.Path.GetFileName(files[0])}{(files.Length > 1 ? $" {files.Length - 1}" : "")}",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(12, 8, 12, 6),
};
panel.Children.Add(header);
// 액션 항목
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(24, 255, 255, 255));
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var textBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
foreach (var (label, icon, prompt) in actions)
{
var capturedPrompt = prompt;
var row = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(4, 1, 4, 1),
Cursor = Cursors.Hand,
};
var stack = new StackPanel { Orientation = Orientation.Horizontal };
stack.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
stack.Children.Add(new TextBlock
{
Text = label, FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = textBrush, VerticalAlignment = VerticalAlignment.Center,
});
row.Child = stack;
row.MouseEnter += (_, _) => row.Background = hoverBrush;
row.MouseLeave += (_, _) => row.Background = Brushes.Transparent;
row.MouseLeftButtonUp += (_, _) =>
{
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
foreach (var f in files) AddAttachedFile(f);
InputBox.Text = capturedPrompt;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
if (_settings.Settings.Llm.DragDropAutoSend)
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
};
panel.Children.Add(row);
}
// "첨부만" 항목
var attachOnly = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(12, 7, 12, 7),
Margin = new Thickness(4, 1, 4, 1),
Cursor = Cursors.Hand,
};
var attachStack = new StackPanel { Orientation = Orientation.Horizontal };
attachStack.Children.Add(new TextBlock
{
Text = "\uE723",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
attachStack.Children.Add(new TextBlock
{
Text = "첨부만", FontSize = 13,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
VerticalAlignment = VerticalAlignment.Center,
});
attachOnly.Child = attachStack;
attachOnly.MouseEnter += (_, _) => attachOnly.Background = hoverBrush;
attachOnly.MouseLeave += (_, _) => attachOnly.Background = Brushes.Transparent;
attachOnly.MouseLeftButtonUp += (_, _) =>
{
if (_dropActionPopup != null) _dropActionPopup.IsOpen = false;
foreach (var f in files) AddAttachedFile(f);
InputBox.Focus();
};
panel.Children.Add(attachOnly);
var container = new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46)),
CornerRadius = new CornerRadius(12),
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
Padding = new Thickness(4, 4, 4, 6),
Child = panel,
MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
Color = Colors.Black, BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
},
};
_dropActionPopup = new Popup
{
PlacementTarget = InputBorder,
Placement = PlacementMode.Top,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
Child = container,
};
_dropActionPopup.IsOpen = true;
}
// ─── /help 도움말 창 ─────────────────────────────────────────────────
private void ShowSlashHelpWindow()
{
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(26, 27, 46));
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var fg2 = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var itemBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(30, 255, 255, 255));
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(40, 255, 255, 255));
var win = new Window
{
Title = "AX Agent — 슬래시 명령어 도움말",
Width = 560, Height = 640, MinWidth = 440, MinHeight = 500,
WindowStyle = WindowStyle.None, AllowsTransparency = true,
Background = Brushes.Transparent, ResizeMode = ResizeMode.CanResize,
WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this,
Icon = Icon,
};
var mainBorder = new Border
{
Background = bg, CornerRadius = new CornerRadius(16),
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1), Margin = new Thickness(10),
Effect = new System.Windows.Media.Effects.DropShadowEffect { Color = Colors.Black, BlurRadius = 20, ShadowDepth = 4, Opacity = 0.3 },
};
var rootGrid = new Grid();
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(52) });
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
// 헤더
var headerBorder = new Border
{
CornerRadius = new CornerRadius(16, 16, 0, 0),
Background = new LinearGradientBrush(
Color.FromRgb(26, 27, 46), Color.FromRgb(59, 78, 204),
new Point(0, 0), new Point(1, 1)),
Padding = new Thickness(20, 0, 20, 0),
};
var headerGrid = new Grid();
var headerStack = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
headerStack.Children.Add(new TextBlock { Text = "\uE946", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 16, Foreground = Brushes.LightCyan, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center });
headerStack.Children.Add(new TextBlock { Text = "슬래시 명령어 (/ Commands)", FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center });
headerGrid.Children.Add(headerStack);
var closeBtn = new Border { Width = 30, Height = 30, CornerRadius = new CornerRadius(8), Background = Brushes.Transparent, Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, VerticalAlignment = VerticalAlignment.Center };
closeBtn.Child = new TextBlock { Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = new SolidColorBrush(Color.FromArgb(136, 170, 255, 204)), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
closeBtn.MouseEnter += (_, _) => closeBtn.Background = new SolidColorBrush(Color.FromArgb(34, 255, 255, 255));
closeBtn.MouseLeave += (_, _) => closeBtn.Background = Brushes.Transparent;
closeBtn.MouseLeftButtonDown += (_, me) => { me.Handled = true; win.Close(); };
headerGrid.Children.Add(closeBtn);
headerBorder.Child = headerGrid;
Grid.SetRow(headerBorder, 0);
rootGrid.Children.Add(headerBorder);
// 콘텐츠
var scroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(20, 14, 20, 20) };
var contentPanel = new StackPanel();
// 설명
contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 });
// 공통 명령어 섹션
AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg,
("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."),
("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다. (/status test: 연결 진단)"),
("/new", "새 대화를 시작합니다."),
("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."),
("/model", "모델 선택 패널을 엽니다."),
("/permissions", "권한 설정 패널을 엽니다."),
("/allowed-tools", "허용 도구(권한) 패널을 엽니다."),
("/settings", "AX Agent 설정 창을 엽니다."),
("/stats", "최근 호출의 토큰 통계를 표시합니다."),
("/cost", "최근 호출의 추정 비용을 표시합니다."),
("/export", "현재 대화를 파일로 내보냅니다."),
("/clear", "현재 대화를 정리하고 새 대화를 시작합니다."));
AddHelpSection(contentPanel, "작업/운영 명령어", "claw-code 명령 체계를 참고한 운영 명령", fg, fg2, accent, itemBg, hoverBg,
("/config", "설정 점검 및 권장안을 제시합니다."),
("/context", "현재 목표/제약/다음 액션을 정리합니다."),
("/session", "세션 핵심 맥락과 다음 할 일을 요약합니다."),
("/usage", "사용 효율을 높이는 팁을 제시합니다."),
("/rename", "현재 대화 이름을 즉시 변경합니다."),
("/feedback", "마지막 응답에 대한 수정 피드백 입력 패널을 엽니다."),
("/skills", "스킬 시스템/브라우저를 엽니다."),
("/sandbox-toggle", "권한 모드를 순환 전환합니다."),
("/statusline", "현재 상태를 한 줄로 요약해 표시합니다."),
("/heapdump", "메모리 사용 현황을 진단합니다."),
("/passes", "반복/패스 관련 설정 프리셋을 순환 전환합니다."),
("/chrome", "인자 없으면 진단, 인자 있으면 브라우저 작업 실행 경로로 라우팅합니다."),
("/stickers", "빠른 상태 스티커 세트를 보여줍니다."),
("/thinkback", "최근 대화 맥락을 요약 회고합니다."),
("/thinkback-play", "회고 내용을 바탕으로 다음 실행 플랜을 제시합니다."),
("/theme", "테마/표현 관련 설정으로 이동합니다."),
("/output-style", "출력 스타일 가이드를 제시합니다."),
("/keybindings", "단축키 효율화 팁을 제시합니다."),
("/privacy-settings", "보안/개인정보 관점 설정 점검표를 제시합니다."),
("/rate-limit-options", "요청 한도 대응 전략을 제시합니다."));
// 개발 명령어 섹션
AddHelpSection(contentPanel, "개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg,
("/review", "변경 코드를 리뷰하고 리스크를 찾습니다."),
("/commit", "변경사항을 확인하고 승인 후 실제 커밋을 실행합니다. (files: 지정 지원)"),
("/ultrareview", "더 엄격한 리뷰 기준으로 치명 리스크를 우선 점검합니다."),
("/security-review", "보안 중심으로 취약점과 개선안을 점검합니다."),
("/pr", "변경사항을 PR 설명 형식으로 정리합니다."),
("/pr-comments", "리뷰 코멘트 형태의 개선 의견을 작성합니다."),
("/test", "테스트 생성/개선 방향을 제시합니다."),
("/verify", "빌드/테스트/리스크 점검까지 포함한 검증 모드로 실행합니다."),
("/structure", "프로젝트 구조를 분석합니다."),
("/build", "빌드 및 오류 분석을 진행합니다."),
("/search", "코드베이스 검색을 수행합니다."),
("/diff", "현재 diff의 핵심 변경점/리스크를 요약합니다."),
("/doctor", "프로젝트/환경 점검 체크를 수행합니다."));
AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg,
("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"),
("/agents", "에이전트 분담 전략 제시"),
("/plugin", "플러그인 구성 점검"),
("/reload-plugins", "플러그인 재로드 점검"),
("/install-github-app", "GitHub 앱 연동 안내"),
("/install-slack-app", "Slack 앱 연동 안내"),
("/remote-env", "원격 환경 연결 점검"),
("/ide", "IDE 연동 점검"),
("/terminal-setup", "터미널 초기 구성 점검"));
// 스킬 명령어 섹션
var skills = SkillService.Skills;
if (skills.Count > 0)
{
var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray();
AddHelpSection(contentPanel, "스킬 명령어", $"{skills.Count}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems);
}
// 사용 팁
contentPanel.Children.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), Margin = new Thickness(0, 12, 0, 12) });
var tipPanel = new StackPanel();
tipPanel.Children.Add(new TextBlock { Text = "사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) });
var tips = new[]
{
"/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.",
"파일을 드래그하면 유형별 AI 액션 팝업이 나타납니다.",
"스킬 파일(*.skill.md)을 추가하면 나만의 워크플로우를 만들 수 있습니다.",
"Cowork/Code 탭에서 에이전트가 도구를 활용하여 더 강력한 작업을 수행합니다.",
};
foreach (var tip in tips)
{
tipPanel.Children.Add(new TextBlock { Text = $"• {tip}", FontSize = 12, Foreground = fg2, Margin = new Thickness(8, 2, 0, 2), TextWrapping = TextWrapping.Wrap, LineHeight = 18 });
}
contentPanel.Children.Add(tipPanel);
scroll.Content = contentPanel;
Grid.SetRow(scroll, 1);
rootGrid.Children.Add(scroll);
mainBorder.Child = rootGrid;
win.Content = mainBorder;
// 헤더 영역에서만 드래그 이동 (닫기 버튼 클릭 방해 방지)
headerBorder.MouseLeftButtonDown += (_, me) => { try { win.DragMove(); } catch { } };
win.ShowDialog();
}
private static void AddHelpSection(StackPanel parent, string title, string subtitle,
Brush fg, Brush fg2, Brush accent, Brush itemBg, Brush hoverBg,
params (string Cmd, string Desc)[] items)
{
parent.Children.Add(new TextBlock { Text = title, FontSize = 14, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 8, 0, 2) });
parent.Children.Add(new TextBlock { Text = subtitle, FontSize = 11, Foreground = fg2, Margin = new Thickness(0, 0, 0, 8) });
foreach (var (cmd, desc) in items)
{
var row = new Border { Background = itemBg, CornerRadius = new CornerRadius(8), Padding = new Thickness(12, 8, 12, 8), Margin = new Thickness(0, 3, 0, 3) };
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var cmdText = new TextBlock { Text = cmd, FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = accent, VerticalAlignment = VerticalAlignment.Center, FontFamily = new FontFamily("Consolas") };
Grid.SetColumn(cmdText, 0);
grid.Children.Add(cmdText);
var descText = new TextBlock { Text = desc, FontSize = 12, Foreground = fg2, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap };
Grid.SetColumn(descText, 1);
grid.Children.Add(descText);
row.Child = grid;
row.MouseEnter += (_, _) => row.Background = hoverBg;
row.MouseLeave += (_, _) => row.Background = itemBg;
parent.Children.Add(row);
}
}
private async Task ExecuteManualCompactAsync(string commandText, string runTab)
{
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null)
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
conv = _currentConversation;
}
var userMsg = new ChatMessage { Role = "user", Content = commandText };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
}
}
SaveLastConversations();
_storage.Save(conv);
ChatSession?.RememberConversation(runTab, conv.Id);
UpdateChatTitle();
AddMessageBubble("user", commandText);
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
ForceScrollToEnd();
var llm = _settings.Settings.Llm;
var beforeTokens = Services.TokenEstimator.EstimateMessages(conv.Messages);
var working = conv.Messages.ToList();
var condensed = await ContextCondenser.CondenseIfNeededAsync(
working,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
true,
CancellationToken.None);
var afterTokens = Services.TokenEstimator.EstimateMessages(working);
RecordCompactionStats(beforeTokens, afterTokens, wasAutomatic: false);
if (condensed)
{
lock (_convLock)
{
conv.Messages = working;
}
}
var assistantText = condensed
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}"
: "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, assistantMsg);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(assistantMsg);
}
}
SaveLastConversations();
_storage.Save(conv);
AddMessageBubble("assistant", assistantText);
ForceScrollToEnd();
if (StatusTokens != null)
StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}";
SetStatus(condensed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false);
RefreshContextUsageVisual();
RefreshConversationList();
UpdateTaskSummaryIndicators();
}
private void AppendLocalSlashResult(string runTab, string commandText, string assistantText)
{
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null)
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
conv = _currentConversation;
}
var userMsg = new ChatMessage { Role = "user", Content = commandText };
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
session.AppendMessage(runTab, assistantMsg);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
conv.Messages.Add(assistantMsg);
}
}
SaveLastConversations();
_storage.Save(conv);
ChatSession?.RememberConversation(runTab, conv.Id);
UpdateChatTitle();
AddMessageBubble("user", commandText);
AddMessageBubble("assistant", assistantText);
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
ForceScrollToEnd();
}
private string BuildSlashStatusText()
{
var llm = _settings.Settings.Llm;
var tab = _activeTab;
var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
var service = llm.Service?.ToLowerInvariant() switch
{
"gemini" => "Gemini",
"sigmoid" or "claude" => "Claude",
"vllm" => "vLLM",
_ => "Ollama",
};
var model = GetCurrentModelDisplayName();
var folder = GetCurrentWorkFolder();
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
return
$"현재 상태\n" +
$"- 탭: {tab}\n" +
$"- 모델: {service} · {model}\n" +
$"- 권한: {permission}\n" +
$"- 작업 폴더: {folderText}\n" +
$"- 스트리밍: {(llm.Streaming ? "ON" : "OFF")}\n" +
$"- 컨텍스트 토큰: {llm.MaxContextTokens:N0}";
}
private string BuildSlashStatsText()
{
var usage = _llm.LastTokenUsage;
if (usage == null)
return "최근 호출 토큰 통계가 아직 없습니다. 대화를 한 번 실행한 뒤 다시 시도하세요.";
return
$"최근 호출 토큰\n" +
$"- 입력: {usage.PromptTokens:N0}\n" +
$"- 출력: {usage.CompletionTokens:N0}\n" +
$"- 합계: {usage.TotalTokens:N0}";
}
private string BuildSlashCostText()
{
var usage = _llm.LastTokenUsage;
if (usage == null)
return "최근 호출 토큰 정보가 없어 비용을 계산할 수 없습니다.";
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
usage.PromptTokens,
usage.CompletionTokens,
_settings.Settings.Llm.Service,
GetCurrentModelDisplayName());
var total = inCost + outCost;
return
$"최근 호출 추정 비용\n" +
$"- 입력 비용: {Services.TokenEstimator.FormatCost(inCost)}\n" +
$"- 출력 비용: {Services.TokenEstimator.FormatCost(outCost)}\n" +
$"- 합계: {Services.TokenEstimator.FormatCost(total)}";
}
private string BuildSlashStatuslineText()
{
var llm = _settings.Settings.Llm;
var (_, model) = _llm.GetCurrentModelInfo();
var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
var folder = GetCurrentWorkFolder();
var folderName = string.IsNullOrWhiteSpace(folder) ? "NoFolder" : System.IO.Path.GetFileName(folder.TrimEnd('\\', '/'));
var historyCount = _currentConversation?.Messages.Count ?? 0;
return $"[{_activeTab}] {ServiceLabel(llm.Service)}/{model} · {permission} · msg {historyCount} · folder {folderName}";
}
private static string MaskEndpoint(string endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint))
return "(미설정)";
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
return endpoint;
return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : ":" + uri.Port)}";
}
private static string NormalizeServiceLabel(string service)
{
return service.Trim().ToLowerInvariant() switch
{
"vllm" => "vLLM",
"gemini" => "Gemini",
"sigmoid" => "Claude",
_ => "Ollama",
};
}
private async Task<string> BuildLlmRuntimeDiagnosisAsync()
{
var snapshot = _llm.GetRuntimeConnectionSnapshot();
var (ok, message) = await _llm.TestConnectionAsync();
var lines = new List<string>
{
"LLM 런타임 진단",
$"- 서비스: {NormalizeServiceLabel(snapshot.Service)}",
$"- 모델: {snapshot.Model}",
$"- 엔드포인트: {MaskEndpoint(snapshot.Endpoint)}",
$"- API 키: {(snapshot.HasApiKey ? "" : "")}",
};
if (string.Equals(snapshot.Service, "vllm", StringComparison.OrdinalIgnoreCase))
lines.Add($"- SSL 인증서 검증 우회: {(snapshot.AllowInsecureTls ? "ON" : "OFF")}");
lines.Add($"- 연결 결과: {(ok ? "" : "")} ({message})");
return string.Join("\n", lines);
}
private bool IsMcpServerEnabled(McpServerEntry server)
{
if (_sessionMcpEnabledOverrides.TryGetValue(server.Name ?? "", out var overridden))
return overridden;
return server.Enabled;
}
private bool IsChromeMcpCandidate(McpServerEntry server)
{
if (!IsMcpServerEnabled(server))
return false;
return
(server.Name?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
(server.Command?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
(server.Url?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 ||
server.Args.Any(a => a.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) >= 0);
}
private async Task<string> BuildChromeRuntimeDiagnosisAsync(CancellationToken ct = default)
{
var servers = _settings.Settings.Llm.McpServers ?? [];
var matches = servers.Where(IsChromeMcpCandidate).ToList();
if (matches.Count == 0)
return "Chrome MCP가 구성되지 않았습니다. 설정에서 MCP 서버를 추가/활성화한 뒤 다시 /chrome 를 실행하세요.";
var lines = new List<string>
{
"Chrome MCP 런타임 진단",
$"- 후보 서버: {matches.Count}개"
};
var connectedCount = 0;
var totalTools = 0;
foreach (var server in matches)
{
var serverName = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
{
lines.Add($"- {serverName}: {transport} 전송은 현재 앱의 런타임 직접 진단 미지원 (구성만 확인)");
continue;
}
using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected)
{
lines.Add($"- {serverName}: 연결 실패");
continue;
}
connectedCount++;
var tools = client.Tools;
totalTools += tools.Count;
lines.Add($"- {serverName}: 연결 성공 (도구 {tools.Count}개)");
var toolPreview = tools
.Select(t => t.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Take(6)
.ToList();
if (toolPreview.Count > 0)
lines.Add($" 도구: {string.Join(", ", toolPreview)}");
}
if (connectedCount == 0)
{
lines.Add("진단 결과: 연결 가능한 Chrome MCP 서버가 없습니다.");
lines.Add("확인 항목: command/args 경로, 실행 권한, Node/NPM 설치 상태, 서버 실행 로그");
return string.Join("\n", lines);
}
lines.Add($"진단 결과: {connectedCount}개 서버 연결 성공, 총 도구 {totalTools}개 확인");
lines.Add("다음 단계: /mcp 또는 /mcp reconnect <서버명> 으로 상태를 갱신하세요.");
return string.Join("\n", lines);
}
private async Task<(bool Ready, List<string> ServerNames, List<string> ToolNames)> ProbeChromeToolingAsync(CancellationToken ct = default)
{
var servers = _settings.Settings.Llm.McpServers ?? [];
var matches = servers.Where(IsChromeMcpCandidate).ToList();
var readyServers = new List<string>();
var toolNames = new List<string>();
if (matches.Count == 0)
return (false, readyServers, toolNames);
foreach (var server in matches)
{
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
continue;
using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected || client.Tools.Count == 0)
continue;
readyServers.Add(string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name);
toolNames.AddRange(client.Tools.Select(t => $"mcp_{t.ServerName}_{t.Name}"));
}
return (readyServers.Count > 0, readyServers, toolNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
internal static string BuildChromeExecutionSystemPrompt(string userRequest, IEnumerable<string> serverNames, IEnumerable<string> toolNames)
{
var serversText = string.Join(", ", serverNames.Take(6));
var toolList = toolNames.Take(16).ToList();
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are executing a browser automation task via MCP tools.");
sb.AppendLine($"Target request: {userRequest}");
sb.AppendLine($"Preferred MCP servers: {serversText}");
if (toolList.Count > 0)
sb.AppendLine($"Preferred tools: {string.Join(", ", toolList)}");
sb.AppendLine("Rules:");
sb.AppendLine("1) Prioritize the preferred MCP browser tools first.");
sb.AppendLine("2) If URL scheme is missing, default to https://.");
sb.AppendLine("3) Execute only the minimum required steps.");
sb.AppendLine("4) Return concise evidence (visited URL, key page text, or action result).");
sb.AppendLine("5) If browser tooling is unavailable, explain blocker and suggest /chrome or /mcp reconnect.");
return sb.ToString();
}
internal static string BuildVerifySystemPrompt(string request)
{
var objective = string.IsNullOrWhiteSpace(request) || string.Equals(request.Trim(), "/verify", StringComparison.OrdinalIgnoreCase)
? "현재 변경사항의 품질 검증을 수행하세요."
: request.Trim();
return
"You are in verification mode.\n" +
$"Verification target: {objective}\n" +
"Required flow:\n" +
"1) Inspect current changes and identify risky files.\n" +
"2) Run build and tests with appropriate tools.\n" +
"3) If failures occur, identify root cause and patch minimally.\n" +
"4) Re-run verification until pass or clear blocker.\n" +
"5) Return a structured report in Korean exactly with sections:\n" +
" [검증결과] PASS|FAIL\n" +
" [실행요약] (build/test/review 실행 내역)\n" +
" [변경파일] (수정한 파일 목록)\n" +
" [잔여리스크] (남은 위험 또는 없음)\n";
}
internal static (string action, string argument) ParseGenericAction(string displayText, string command)
{
var raw = (displayText ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, command, StringComparison.OrdinalIgnoreCase))
return ("open", "");
var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length <= 1)
return (parts[0].Trim().ToLowerInvariant(), "");
return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim());
}
internal static (List<string> SelectedFiles, string CommitMessage) ParseCommitCommandInput(string displayText)
{
var raw = (displayText ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "/commit", StringComparison.OrdinalIgnoreCase))
return (new List<string>(), "");
if (!raw.StartsWith("files:", StringComparison.OrdinalIgnoreCase))
return (new List<string>(), raw);
var body = raw["files:".Length..].Trim();
var split = body.Split(new[] { "::" }, 2, StringSplitOptions.None);
var filesPart = split[0].Trim();
var msgPart = split.Length >= 2 ? split[1].Trim() : "";
var files = filesPart
.Split([','], StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return (files, msgPart);
}
private static string? FindGitExecutablePath()
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo("where.exe", "git")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
return null;
var output = proc.StandardOutput.ReadToEnd().Trim();
proc.WaitForExit(5000);
return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim();
}
catch
{
return null;
}
}
private static string? ResolveGitRoot(string? workFolder)
{
if (string.IsNullOrWhiteSpace(workFolder) || !System.IO.Directory.Exists(workFolder))
return null;
var dir = new System.IO.DirectoryInfo(workFolder);
while (dir != null)
{
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
return dir.FullName;
dir = dir.Parent;
}
return null;
}
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunGitAsync(
string gitPath,
string workDir,
IEnumerable<string> args,
CancellationToken ct = default)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = gitPath,
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = System.Text.Encoding.UTF8,
StandardErrorEncoding = System.Text.Encoding.UTF8,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
return (-1, "", "git 프로세스를 시작하지 못했습니다.");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
var stdoutTask = proc.StandardOutput.ReadToEndAsync(timeoutCts.Token);
var stderrTask = proc.StandardError.ReadToEndAsync(timeoutCts.Token);
await proc.WaitForExitAsync(timeoutCts.Token);
var stdout = await stdoutTask;
var stderr = await stderrTask;
return (proc.ExitCode, stdout, stderr);
}
private async Task<string> ExecuteCommitWithApprovalAsync(string? displayText, CancellationToken ct = default)
{
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
return "커밋 실행은 Cowork/Code 탭에서만 지원됩니다.";
}
var folder = GetCurrentWorkFolder();
var gitRoot = ResolveGitRoot(folder);
if (string.IsNullOrWhiteSpace(gitRoot))
return "Git 저장소를 찾지 못했습니다. 작업 폴더를 Git 프로젝트로 설정해 주세요.";
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return "Git 실행 파일을 찾지 못했습니다. Git 설치 및 PATH를 확인해 주세요.";
var status = await RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct).ConfigureAwait(false);
if (status.ExitCode != 0)
return $"git status 실패:\n{status.StdErr.Trim()}";
var changedLines = status.StdOut
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
if (changedLines.Count == 0)
return "커밋할 변경사항이 없습니다.";
var (selectedFilesRaw, parsedMessage) = ParseCommitCommandInput(displayText ?? "");
var changedPathSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in changedLines)
{
var path = line.Length > 3 ? line[3..].Trim() : line.Trim();
if (string.IsNullOrWhiteSpace(path))
continue;
if (path.Contains("->", StringComparison.Ordinal))
{
var renameParts = path.Split(new[] { "->" }, 2, StringSplitOptions.None);
if (renameParts.Length == 2)
{
var before = renameParts[0].Trim();
var after = renameParts[1].Trim();
if (!string.IsNullOrWhiteSpace(before)) changedPathSet.Add(before);
if (!string.IsNullOrWhiteSpace(after)) changedPathSet.Add(after);
continue;
}
}
changedPathSet.Add(path);
}
var selectedFiles = selectedFilesRaw.Count == 0
? changedPathSet.ToList()
: selectedFilesRaw
.Where(p => changedPathSet.Contains(p))
.ToList();
if (selectedFiles.Count == 0)
{
if (selectedFilesRaw.Count > 0)
return "지정한 파일이 현재 변경 목록에 없습니다. /commit files:<경로1,경로2> :: <메시지> 형식을 확인해 주세요.";
return "커밋할 변경 파일을 찾지 못했습니다.";
}
var commitMessage = string.IsNullOrWhiteSpace(parsedMessage)
? $"작업: 변경사항 반영 ({selectedFiles.Count}개 파일)"
: parsedMessage;
var previewFiles = selectedFiles
.Take(8)
.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
var previewText = previewFiles.Count == 0
? "(파일 목록 없음)"
: string.Join("\n", previewFiles.Select(f => $"- {f}"));
var confirm = CustomMessageBox.Show(
$"다음 내용으로 커밋을 진행할까요?\n\n저장소: {gitRoot}\n메시지: {commitMessage}\n\n커밋 대상 파일(일부):\n{previewText}",
"커밋 승인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
return "커밋 실행을 취소했습니다.";
var addArgs = new List<string> { "add" };
if (selectedFilesRaw.Count == 0)
{
addArgs.Add("-A");
}
else
{
addArgs.Add("--");
addArgs.AddRange(selectedFiles);
}
var add = await RunGitAsync(gitPath, gitRoot, addArgs, ct).ConfigureAwait(false);
if (add.ExitCode != 0)
return $"git add 실패:\n{add.StdErr.Trim()}";
var commit = await RunGitAsync(gitPath, gitRoot, new[] { "commit", "-m", commitMessage }, ct).ConfigureAwait(false);
if (commit.ExitCode != 0)
{
var err = string.IsNullOrWhiteSpace(commit.StdErr) ? commit.StdOut : commit.StdErr;
return $"git commit 실패:\n{err.Trim()}";
}
var head = await RunGitAsync(gitPath, gitRoot, new[] { "log", "--oneline", "-1" }, ct).ConfigureAwait(false);
var headLine = (head.StdOut ?? "").Trim();
return
$"커밋이 완료되었습니다.\n" +
$"- 메시지: {commitMessage}\n" +
$"- 커밋 파일 수: {selectedFiles.Count}\n" +
(string.IsNullOrWhiteSpace(headLine) ? "" : $"- HEAD: {headLine}\n") +
"- 원격 반영은 사용자가 직접 push 해주세요.\n" +
"- 팁: /commit files:path1,path2 :: 메시지 형태로 부분 커밋할 수 있습니다.";
}
internal static (string action, string target) ParseMcpAction(string displayText)
{
var text = (displayText ?? "").Trim();
if (string.IsNullOrWhiteSpace(text) || string.Equals(text, "/mcp", StringComparison.OrdinalIgnoreCase))
return ("status", "");
var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return ("status", "");
var action = parts[0].Trim().ToLowerInvariant();
var target = parts.Length >= 2 ? string.Join(' ', parts.Skip(1)).Trim() : "";
return action switch
{
"enable" => ("enable", target),
"disable" => ("disable", target),
"reconnect" => ("reconnect", target),
"add" => ("add", target),
"remove" => ("remove", target),
"reset" => ("reset", target),
"login" => ("login", target),
"logout" => ("logout", target),
"status" => ("status", target),
_ => ("help", text),
};
}
internal static (bool success, string serverTarget, string token, string error) ParseMcpLoginTarget(string target)
{
var raw = (target ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw))
return (false, "", "", "사용법: /mcp login <서버명> <토큰>");
var firstSpace = raw.IndexOf(' ');
if (firstSpace <= 0 || firstSpace >= raw.Length - 1)
return (false, "", "", "토큰이 누락되었습니다. 사용법: /mcp login <서버명> <토큰>");
var serverTarget = raw[..firstSpace].Trim();
var token = raw[(firstSpace + 1)..].Trim();
if (string.IsNullOrWhiteSpace(serverTarget))
return (false, "", "", "서버명이 비어 있습니다.");
if (string.IsNullOrWhiteSpace(token))
return (false, "", "", "토큰이 비어 있습니다.");
return (true, serverTarget, token, "");
}
internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target)
{
var raw = (target ?? "").Trim();
if (string.IsNullOrWhiteSpace(raw))
return (false, null, "사용법: /mcp add <서버명> :: stdio <명령> [인자...] | /mcp add <서버명> :: sse <URL>");
var sep = raw.IndexOf("::", StringComparison.Ordinal);
if (sep < 0)
return (false, null, "추가 형식이 올바르지 않습니다. 구분자 `::` 를 사용하세요.\n예: /mcp add chrome :: stdio node server.js");
var name = raw[..sep].Trim();
var spec = raw[(sep + 2)..].Trim();
if (string.IsNullOrWhiteSpace(name))
return (false, null, "서버명이 비어 있습니다. /mcp add <서버명> :: ... 형식으로 입력하세요.");
if (string.IsNullOrWhiteSpace(spec))
return (false, null, "연결 정보가 비어 있습니다. stdio 또는 sse 설정을 입력하세요.");
var tokens = TokenizeCommand(spec);
if (tokens.Count < 2)
return (false, null, "연결 정보가 부족합니다. 예: stdio node server.js 또는 sse https://host/sse");
var transport = tokens[0].Trim().ToLowerInvariant();
if (transport == "stdio")
{
var command = tokens[1].Trim();
if (string.IsNullOrWhiteSpace(command))
return (false, null, "stdio 방식은 실행 명령(command)이 필요합니다.");
var args = tokens.Count > 2 ? tokens.Skip(2).ToList() : new List<string>();
return (true, new McpServerEntry
{
Name = name,
Transport = "stdio",
Command = command,
Args = args,
Enabled = true,
}, "");
}
if (transport == "sse")
{
var url = string.Join(" ", tokens.Skip(1)).Trim();
if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed))
return (false, null, $"유효하지 않은 SSE URL 입니다: {url}");
if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
return (false, null, "SSE URL은 http 또는 https 스킴만 지원합니다.");
return (true, new McpServerEntry
{
Name = name,
Transport = "sse",
Url = url,
Enabled = true,
}, "");
}
return (false, null, $"지원하지 않는 transport 입니다: {transport}. 사용 가능 값: stdio, sse");
}
internal static List<string> TokenizeCommand(string input)
{
var text = input ?? "";
var tokens = new List<string>();
var sb = new System.Text.StringBuilder();
var inQuote = false;
foreach (var ch in text)
{
if (ch == '"')
{
inQuote = !inQuote;
continue;
}
if (!inQuote && char.IsWhiteSpace(ch))
{
if (sb.Length == 0) continue;
tokens.Add(sb.ToString());
sb.Clear();
continue;
}
sb.Append(ch);
}
if (sb.Length > 0)
tokens.Add(sb.ToString());
return tokens;
}
private async Task<string> BuildMcpRuntimeStatusTextAsync(
IEnumerable<McpServerEntry>? source = null,
bool runtimeCheck = true,
CancellationToken ct = default)
{
var servers = (source ?? _settings.Settings.Llm.McpServers ?? []).ToList();
if (servers.Count == 0)
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 /mcp 를 다시 실행하세요.";
var lines = new List<string>
{
"MCP 상태",
$"- 전체: {servers.Count}개",
$"- 활성(세션 기준): {servers.Count(IsMcpServerEnabled)}개"
};
foreach (var server in servers.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
{
var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : "";
if (!IsMcpServerEnabled(server))
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
if (!runtimeCheck)
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected)
{
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
continue;
}
var toolCount = client.Tools.Count;
var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount);
lines.Add($"- {name} [{transport}] : {statusLabel}{authSuffix}");
}
lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset | login <서버명> <토큰> | logout <서버명|all>");
return string.Join("\n", lines);
}
internal static string ResolveMcpDisplayStatus(bool isEnabled, string transport, bool runtimeCheck, bool connected, int? toolCount)
{
var normalizedTransport = string.IsNullOrWhiteSpace(transport) ? "stdio" : transport.Trim().ToLowerInvariant();
if (!isEnabled)
return "Disabled";
if (!runtimeCheck)
return "Enabled";
if (!string.Equals(normalizedTransport, "stdio", StringComparison.OrdinalIgnoreCase))
return "Configured";
if (!connected)
return "Disconnected";
if ((toolCount ?? 0) <= 0)
return "NeedsAuth (도구 0개)";
return $"Connected (도구 {toolCount}개)";
}
private string ResolveMcpServerName(IEnumerable<McpServerEntry> servers, string inputName)
{
var trimmed = inputName.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
return "";
var exact = servers.FirstOrDefault(s => string.Equals(s.Name, trimmed, StringComparison.OrdinalIgnoreCase));
if (exact != null)
return exact.Name;
var partial = servers.FirstOrDefault(s => (s.Name?.IndexOf(trimmed, StringComparison.OrdinalIgnoreCase) ?? -1) >= 0);
return partial?.Name ?? "";
}
private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server)
{
var clone = new McpServerEntry
{
Name = server.Name,
Command = server.Command,
Args = server.Args?.ToList() ?? new List<string>(),
Env = new Dictionary<string, string>(server.Env ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
Enabled = server.Enabled,
Transport = server.Transport,
Url = server.Url,
};
var key = clone.Name ?? "";
if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token))
clone.Env["MCP_AUTH_TOKEN"] = token;
return clone;
}
private async Task<string> HandleMcpSlashAsync(string displayText, CancellationToken ct = default)
{
var llm = _settings.Settings.Llm;
llm.McpServers ??= new List<McpServerEntry>();
var servers = llm.McpServers;
var (action, target) = ParseMcpAction(displayText);
if (action == "help")
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
if (action == "status")
return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
if (action == "reset")
{
var changed = _sessionMcpEnabledOverrides.Count;
_sessionMcpEnabledOverrides.Clear();
_sessionMcpAuthTokens.Clear();
return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action == "login")
{
var (ok, serverTarget, token, error) = ParseMcpLoginTarget(target);
if (!ok)
return error;
var resolved = ResolveMcpServerName(servers, serverTarget);
if (string.IsNullOrWhiteSpace(resolved))
return $"로그인 대상 서버를 찾지 못했습니다: {serverTarget}";
_sessionMcpAuthTokens[resolved] = token;
return $"MCP 세션 토큰을 설정했습니다: {resolved}\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
}
if (action == "logout")
{
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
var removed = _sessionMcpAuthTokens.Count;
_sessionMcpAuthTokens.Clear();
return $"모든 MCP 세션 토큰을 제거했습니다. ({removed}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"로그아웃 대상 서버를 찾지 못했습니다: {target}";
var removedOne = _sessionMcpAuthTokens.Remove(resolved);
return $"MCP 세션 토큰 제거: {resolved} ({(removedOne ? 1 : 0)}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action == "add")
{
var (ok, entry, error) = ParseMcpAddTarget(target);
if (!ok || entry == null)
return error;
var duplicate = servers.Any(s => string.Equals(s.Name, entry.Name, StringComparison.OrdinalIgnoreCase));
if (duplicate)
return $"동일한 이름의 MCP 서버가 이미 존재합니다: {entry.Name}\n기존 항목을 수정하거나 /mcp remove {entry.Name} 후 다시 추가하세요.";
servers.Add(entry);
_settings.Save();
_sessionMcpEnabledOverrides.Remove(entry.Name);
return $"MCP 서버를 추가했습니다: {entry.Name} ({entry.Transport})\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (servers.Count == 0)
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요.";
if (action == "remove")
{
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
var removed = servers.Count;
servers.Clear();
_sessionMcpEnabledOverrides.Clear();
_sessionMcpAuthTokens.Clear();
_settings.Save();
return $"MCP 서버를 모두 제거했습니다. ({removed}개)";
}
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"제거 대상 서버를 찾지 못했습니다: {target}";
var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
_sessionMcpEnabledOverrides.Remove(resolved);
_sessionMcpAuthTokens.Remove(resolved);
_settings.Save();
return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action is "enable" or "disable")
{
var newEnabled = action == "enable";
int changed;
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
changed = 0;
foreach (var server in servers.Where(s => IsMcpServerEnabled(s) != newEnabled))
{
_sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled;
changed++;
}
}
else
{
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"대상 서버를 찾지 못했습니다: {target}";
var server = servers.First(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
changed = IsMcpServerEnabled(server) == newEnabled ? 0 : 1;
_sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled;
}
var status = newEnabled ? "활성화" : "비활성화";
return $"MCP 서버 {status} 완료 ({changed}개 변경, 현재 세션에만 적용)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
}
if (action == "reconnect")
{
List<McpServerEntry> targetServers;
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
{
targetServers = servers.Where(IsMcpServerEnabled).ToList();
}
else
{
var resolved = ResolveMcpServerName(servers, target);
if (string.IsNullOrWhiteSpace(resolved))
return $"재연결 대상 서버를 찾지 못했습니다: {target}";
targetServers = servers.Where(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)).ToList();
}
if (targetServers.Count == 0)
return "재연결할 활성 MCP 서버가 없습니다.";
return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false);
}
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
}
private string BuildSlashHeapDumpText()
{
var proc = System.Diagnostics.Process.GetCurrentProcess();
var managedBytes = GC.GetTotalMemory(forceFullCollection: false);
var workingSet = proc.WorkingSet64;
var privateBytes = proc.PrivateMemorySize64;
return
$"메모리 진단 (heapdump)\n" +
$"- Managed Heap: {managedBytes / 1024.0 / 1024.0:F1} MB\n" +
$"- Working Set: {workingSet / 1024.0 / 1024.0:F1} MB\n" +
$"- Private Bytes: {privateBytes / 1024.0 / 1024.0:F1} MB\n" +
$"- GC Gen0/1/2: {GC.CollectionCount(0)}/{GC.CollectionCount(1)}/{GC.CollectionCount(2)}";
}
private string CyclePassPreset()
{
var llm = _settings.Settings.Llm;
var current = llm.MaxAgentIterations <= 16 ? 16 : llm.MaxAgentIterations <= 25 ? 25 : 40;
var next = current switch
{
16 => 25,
25 => 40,
_ => 16,
};
llm.MaxAgentIterations = next;
_settings.Save();
return $"Agent Pass 프리셋을 {next}로 변경했습니다.";
}
private string BuildThinkbackSummaryText()
{
var conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0)
return "회고할 대화가 없습니다.";
var recent = conv.Messages
.Where(m => m.Role is "user" or "assistant")
.TakeLast(10)
.ToList();
if (recent.Count == 0)
return "회고할 대화가 없습니다.";
var userCount = recent.Count(m => m.Role == "user");
var assistantCount = recent.Count(m => m.Role == "assistant");
var latestRun = _appState.GetLatestConversationRun(conv.AgentRunHistory);
var highlights = recent
.TakeLast(4)
.Select(m => $"- {m.Role}: {TruncateForStatus((m.Content ?? "").Replace("\r", " ").Replace("\n", " "), 80)}");
return
$"최근 대화 회고\n" +
$"- 최근 메시지: {recent.Count}개 (user {userCount}, assistant {assistantCount})\n" +
$"- 최근 실행 상태: {(latestRun == null ? " " : $"{latestRun.Status} · {TruncateForStatus(latestRun.Summary, 40)}")}\n" +
string.Join("\n", highlights);
}
private string BuildThinkbackPlayText()
{
var conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0)
return "재생할 대화 이력이 없습니다.";
var lastUser = conv.Messages.LastOrDefault(m => m.Role == "user")?.Content ?? "(없음)";
var lastAssistant = conv.Messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? "(없음)";
var folder = GetCurrentWorkFolder();
var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder;
return
$"회고 기반 실행 플랜\n" +
$"1. 마지막 사용자 요청 재확인: {TruncateForStatus(lastUser.Replace('\n', ' '), 72)}\n" +
$"2. 마지막 응답의 보강 포인트 점검: {TruncateForStatus(lastAssistant.Replace('\n', ' '), 72)}\n" +
$"3. 작업 폴더 기준 실행/검증: {TruncateForStatus(folderText, 52)}\n" +
$"4. 부족한 근거/테스트 보강 후 다음 답변 생성";
}
private void OpenSkillsFromSlash()
{
var llm = _settings.Settings.Llm;
if (!llm.EnableSkillSystem)
{
llm.EnableSkillSystem = true;
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(llm.SkillsFolderPath);
UpdateConditionalSkillActivation(reset: true);
_settings.Save();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
}
OpenCommandSkillBrowser("/");
}
private string TogglePermissionModeFromSlash()
{
var llm = _settings.Settings.Llm;
llm.FilePermission = NextPermission(llm.FilePermission);
_settings.Save();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
return PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
}
private bool PromptRenameConversationFromSlash(out string renamedTitle)
{
renamedTitle = "";
ChatConversation? conv;
lock (_convLock)
{
_currentConversation ??= ChatSession?.EnsureCurrentConversation(_activeTab) ?? new ChatConversation { Tab = _activeTab };
conv = _currentConversation;
}
var currentTitle = string.IsNullOrWhiteSpace(conv.Title) ? "새 대화" : conv.Title;
var dlg = new Views.InputDialog("대화 이름 변경", "새 대화 이름:", currentTitle) { Owner = this };
if (dlg.ShowDialog() != true)
return false;
var newTitle = dlg.ResponseText.Trim();
if (string.IsNullOrWhiteSpace(newTitle) || string.Equals(newTitle, currentTitle, StringComparison.Ordinal))
return false;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
_currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage);
conv = _currentConversation;
}
else if (conv != null)
{
conv.Title = newTitle;
_storage.Save(conv);
}
}
SaveLastConversations();
UpdateChatTitle();
RefreshConversationList();
renamedTitle = newTitle;
return true;
}
private async Task SendMessageAsync()
{
var rawText = InputBox.Text.Trim();
// 슬래시 칩이 활성화된 경우 명령어 앞에 붙임
var text = _slashPalette.ActiveCommand != null
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
: rawText;
HideSlashChip(restoreText: false);
if (string.IsNullOrEmpty(text) || _isStreaming) return;
// placeholder 정리
ClearPromptCardPlaceholder();
// 슬래시 명령어 처리
var (slashSystem, displayText) = ParseSlashCommand(text);
if (string.Equals(slashSystem, "__CLEAR__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/clear", "현재 대화를 정리하고 새 대화를 시작합니다.");
StartNewConversation();
return;
}
if (string.Equals(slashSystem, "__NEW__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/new", "새 대화를 시작합니다.");
StartNewConversation();
return;
}
if (string.Equals(slashSystem, "__RESET__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/reset", "현재 세션 컨텍스트를 초기화하고 새 대화를 시작합니다.");
StartNewConversation();
return;
}
if (string.Equals(slashSystem, "__STATUS__", StringComparison.Ordinal))
{
var (statusAction, _) = ParseGenericAction(displayText ?? "", "/status");
if (statusAction is "test" or "diag" or "connection" or "connect")
{
var diagnosis = await BuildLlmRuntimeDiagnosisAsync();
AppendLocalSlashResult(_activeTab, "/status", diagnosis);
return;
}
AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText() + "\n(연결 점검: /status test)");
return;
}
if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal))
{
var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions");
if (TryApplyPermissionModeFromAction(permAction, out var appliedMode))
{
AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(appliedMode)}({appliedMode})로 변경했습니다.\n{BuildPermissionStatusText()}");
return;
}
if (permAction == "status")
{
AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|accept|plan|bypass|status");
return;
}
OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|accept|plan|bypass|status");
return;
}
if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal))
{
var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools");
if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode))
{
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(allowedMode)}({allowedMode})로 변경했습니다.\n{BuildPermissionStatusText()}");
return;
}
if (toolAction == "status")
{
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|accept|plan|bypass|status");
return;
}
OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|accept|plan|bypass|status");
return;
}
if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal))
{
BtnModelSelector_Click(this, new RoutedEventArgs());
AppendLocalSlashResult(_activeTab, "/model", "모델 선택 패널을 열었습니다.");
return;
}
if (string.Equals(slashSystem, "__SETTINGS__", StringComparison.Ordinal))
{
var (settingsAction, _) = ParseGenericAction(displayText ?? "", "/settings");
if (settingsAction == "model")
{
BtnModelSelector_Click(this, new RoutedEventArgs());
AppendLocalSlashResult(_activeTab, "/settings", "모델 선택 패널을 열었습니다.");
return;
}
if (settingsAction == "permissions")
{
OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions");
return;
}
if (settingsAction == "mcp")
{
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. MCP 항목에서 서버를 관리하세요.");
return;
}
if (settingsAction == "theme")
{
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. 테마 항목을 확인하세요.");
return;
}
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. (사용법: /settings model|permissions|mcp|theme)");
return;
}
if (string.Equals(slashSystem, "__THEME__", StringComparison.Ordinal))
{
OpenAgentSettingsWindow();
AppendLocalSlashResult(_activeTab, "/theme", "설정 창을 열었습니다. AX Agent 테마를 변경할 수 있습니다.");
return;
}
if (string.Equals(slashSystem, "__STATS__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/stats", BuildSlashStatsText());
return;
}
if (string.Equals(slashSystem, "__COST__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/cost", BuildSlashCostText());
return;
}
if (string.Equals(slashSystem, "__EXPORT__", StringComparison.Ordinal))
{
ExportConversation();
AppendLocalSlashResult(_activeTab, "/export", "현재 대화를 내보냈습니다.");
return;
}
if (string.Equals(slashSystem, "__STATUSLINE__", StringComparison.Ordinal))
{
var line = BuildSlashStatuslineText();
SetStatus(line, spinning: false);
AppendLocalSlashResult(_activeTab, "/statusline", line);
return;
}
if (string.Equals(slashSystem, "__HEAPDUMP__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/heapdump", BuildSlashHeapDumpText());
return;
}
if (string.Equals(slashSystem, "__PASSES__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/passes", CyclePassPreset());
return;
}
if (string.Equals(slashSystem, "__CHROME__", StringComparison.Ordinal))
{
var chromeInput = (displayText ?? "").Trim();
var hasArgs = !string.IsNullOrWhiteSpace(chromeInput)
&& !string.Equals(chromeInput, "/chrome", StringComparison.OrdinalIgnoreCase);
if (!hasArgs)
{
var diagnosis = await BuildChromeRuntimeDiagnosisAsync();
AppendLocalSlashResult(_activeTab, "/chrome", diagnosis);
return;
}
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
AppendLocalSlashResult(_activeTab, "/chrome",
"브라우저 제어 실행은 Cowork/Code 탭에서 지원됩니다. 탭 전환 후 다시 실행해 주세요.\n" +
$"예: /chrome {chromeInput}");
return;
}
var probe = await ProbeChromeToolingAsync();
if (!probe.Ready)
{
var reconnectResult = await HandleMcpSlashAsync("/mcp reconnect all");
probe = await ProbeChromeToolingAsync();
if (probe.Ready)
{
slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames);
text = chromeInput;
SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비(재연결 성공)", spinning: false);
AppendLocalSlashResult(_activeTab, "/chrome", "사전 점검에서 연결이 부족해 /mcp reconnect all을 자동 실행했고 재시도에 성공했습니다.");
goto CHROME_ROUTING_READY;
}
var diagnosis = await BuildChromeRuntimeDiagnosisAsync();
AppendLocalSlashResult(_activeTab, "/chrome",
"브라우저 MCP 도구가 준비되지 않아 실행을 시작하지 못했습니다.\n" +
"자동 재시도(/mcp reconnect all) 결과:\n" + reconnectResult + "\n\n" + diagnosis);
return;
}
slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames);
text = chromeInput;
SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비", spinning: false);
CHROME_ROUTING_READY:;
}
if (string.Equals(slashSystem, "__MCP__", StringComparison.Ordinal))
{
var mcpResult = await HandleMcpSlashAsync(displayText ?? "");
AppendLocalSlashResult(_activeTab, "/mcp", mcpResult);
return;
}
if (string.Equals(slashSystem, "__STICKERS__", StringComparison.Ordinal))
{
var queueSummary = _appState.GetDraftQueueSummary(_activeTab);
var activeCount = _appState.ActiveTasks.Count;
AppendLocalSlashResult(_activeTab, "/stickers",
"빠른 상태 스티커\n" +
$"- [RUN] 진행중 작업 {activeCount}\n" +
$"- [QUEUE] 대기 {queueSummary.QueuedCount}\n" +
$"- [BLOCK] 승인 대기 {queueSummary.BlockedCount}\n" +
"- [DONE] 완료 후 /statusline 으로 상태 확인");
return;
}
if (string.Equals(slashSystem, "__THINKBACK__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/thinkback", BuildThinkbackSummaryText());
return;
}
if (string.Equals(slashSystem, "__THINKBACK_PLAY__", StringComparison.Ordinal))
{
AppendLocalSlashResult(_activeTab, "/thinkback-play", BuildThinkbackPlayText());
return;
}
if (string.Equals(slashSystem, "__SKILLS__", StringComparison.Ordinal))
{
OpenSkillsFromSlash();
AppendLocalSlashResult(_activeTab, "/skills", "스킬 브라우저를 열었습니다.");
return;
}
if (string.Equals(slashSystem, "__SANDBOX_TOGGLE__", StringComparison.Ordinal))
{
var mode = TogglePermissionModeFromSlash();
AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(mode)}({mode})로 변경했습니다.");
return;
}
if (string.Equals(slashSystem, "__RENAME__", StringComparison.Ordinal))
{
if (PromptRenameConversationFromSlash(out var renamedTitle))
AppendLocalSlashResult(_activeTab, "/rename", $"대화 이름을 \"{renamedTitle}\"로 변경했습니다.");
else
AppendLocalSlashResult(_activeTab, "/rename", "이름 변경을 취소했습니다.");
return;
}
if (string.Equals(slashSystem, "__FEEDBACK__", StringComparison.Ordinal))
{
ChatConversation? currentConv;
lock (_convLock)
currentConv = _currentConversation;
var hasAssistant = currentConv?.Messages.Any(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) == true;
if (!hasAssistant)
{
AppendLocalSlashResult(_activeTab, "/feedback", "피드백할 이전 응답이 없습니다. 먼저 대화를 진행한 뒤 다시 시도하세요.");
return;
}
ShowRetryWithFeedbackInput();
AppendLocalSlashResult(_activeTab, "/feedback", "수정 피드백 입력 패널을 열었습니다.");
return;
}
if (string.Equals(slashSystem, "__VERIFY__", StringComparison.Ordinal))
{
if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
{
AppendLocalSlashResult(_activeTab, "/verify", "검증 모드는 Cowork/Code 탭에서만 실행할 수 있습니다.");
return;
}
slashSystem = BuildVerifySystemPrompt(displayText ?? "");
text = string.IsNullOrWhiteSpace(displayText) ? "현재 변경사항 검증 실행" : displayText;
SetStatus("검증 모드 실행 준비...", spinning: false);
}
if (string.Equals(slashSystem, "__COMMIT__", StringComparison.Ordinal))
{
var commitResult = await ExecuteCommitWithApprovalAsync(displayText, _streamCts?.Token ?? CancellationToken.None);
AppendLocalSlashResult(_activeTab, "/commit", commitResult);
return;
}
if (string.Equals(slashSystem, "__COMPACT__", StringComparison.Ordinal))
{
await ExecuteManualCompactAsync("/compact", _activeTab);
return;
}
// 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처
var originTab = _activeTab;
var runTab = originTab;
var queuedDraftId = _runningDraftId;
var draftSucceeded = false;
var draftCancelled = false;
string? draftFailure = null;
var lastAutoSaveUtc = DateTime.UtcNow;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null)
_currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab };
conv = _currentConversation;
}
var userMsg = new ChatMessage { Role = "user", Content = text };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
if (conv.Messages.Count(m => m.Role == "user") == 1)
conv.Title = text.Length > 30 ? text[..30] + "…" : text;
}
}
SaveLastConversations();
void TryPersistConversation(bool force = false)
{
if (!force && (DateTime.UtcNow - lastAutoSaveUtc).TotalSeconds < 1.2)
return;
lastAutoSaveUtc = DateTime.UtcNow;
try
{
_storage.Save(conv);
ChatSession?.RememberConversation(originTab, conv.Id);
}
catch (Exception ex)
{
Services.LogService.Debug($"대화 중간 저장 실패: {ex.Message}");
}
}
TryPersistConversation(force: true);
UpdateChatTitle();
AddMessageBubble("user", text);
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
// 대화 통계 기록
Services.UsageStatisticsService.RecordChat(runTab);
ForceScrollToEnd(); // 사용자 메시지 전송 시 강제 하단 이동
PlayRainbowGlow(); // 무지개 글로우 애니메이션
_isStreaming = true;
_streamRunTab = runTab;
BtnSend.IsEnabled = false;
BtnSend.Visibility = Visibility.Collapsed;
BtnStop.Visibility = Visibility.Visible;
if (runTab == "Cowork" || runTab == "Code")
BtnPause.Visibility = Visibility.Visible;
_streamCts = new CancellationTokenSource();
var assistantMsg = new ChatMessage { Role = "assistant", Content = "" };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, assistantMsg);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(assistantMsg);
}
}
TryPersistConversation(force: true);
// 어시스턴트 스트리밍 컨테이너
var streamContainer = CreateStreamingContainer(out var streamText);
MessagePanel.Children.Add(streamContainer);
ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동
var sb = new System.Text.StringBuilder();
_activeStreamText = streamText;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_cursorTimer.Start();
_typingTimer.Start();
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus("응답 생성 중...", spinning: true);
// ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ──
ModelRouteResult? routeResult = null;
try
{
List<ChatMessage> sendMessages;
lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList();
// 시스템 명령어가 있으면 삽입
if (!string.IsNullOrEmpty(conv.SystemCommand))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand });
// 슬래시 명령어 시스템 프롬프트 삽입
if (!string.IsNullOrEmpty(slashSystem))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = slashSystem });
// 첨부 파일 컨텍스트 삽입
if (_attachedFiles.Count > 0)
{
var fileContext = BuildFileContextPrompt();
if (!string.IsNullOrEmpty(fileContext))
{
var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user");
if (lastUserIdx >= 0)
sendMessages[lastUserIdx] = new ChatMessage { Role = "user", Content = sendMessages[lastUserIdx].Content + fileContext };
}
// 첨부 파일 목록 기록 후 항상 정리 (파일 읽기 실패해도)
userMsg.AttachedFiles = _attachedFiles.ToList();
_attachedFiles.Clear();
RefreshAttachedFilesUI();
}
// ── 이미지 첨부 ──
if (_pendingImages.Count > 0)
{
userMsg.Images = _pendingImages.ToList();
// 마지막 사용자 메시지에 이미지 데이터 연결
var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user");
if (lastUserIdx >= 0)
sendMessages[lastUserIdx] = new ChatMessage
{
Role = "user",
Content = sendMessages[lastUserIdx].Content,
Images = _pendingImages.ToList(),
};
_pendingImages.Clear();
AttachedFilesPanel.Items.Clear();
if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed;
}
// ── 전송 전 컨텍스트 사전 압축 ──
{
var llm = _settings.Settings.Llm;
var beforeCompactTokens = Services.TokenEstimator.EstimateMessages(sendMessages);
var condensed = await ContextCondenser.CondenseIfNeededAsync(
sendMessages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
false,
_streamCts!.Token);
if (condensed)
{
var afterCompactTokens = Services.TokenEstimator.EstimateMessages(sendMessages);
RecordCompactionStats(beforeCompactTokens, afterCompactTokens, wasAutomatic: true);
SetStatus("컨텍스트를 사전 정리했습니다", spinning: true);
RefreshContextUsageVisual();
}
}
// ── 자동 모델 라우팅 ──
if (_settings.Settings.Llm.EnableAutoRouter)
{
routeResult = _router.Route(text);
if (routeResult != null)
{
_llm.PushRouteOverride(routeResult.Service, routeResult.Model);
SetStatus($"라우팅: {routeResult.DetectedIntent} → {routeResult.DisplayName}", spinning: true);
}
}
if (runTab == "Cowork")
{
// 워크플로우 분석기 자동 열기
OpenWorkflowAnalyzerIfEnabled();
// 누적 토큰 초기화
_agentCumulativeInputTokens = 0;
_agentCumulativeOutputTokens = 0;
// 코워크 탭: 에이전트 루프 사용
_agentLoop.EventOccurred += OnAgentEvent;
// 사용자 의사결정 콜백 — PlanViewerWindow로 계획 표시
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
try
{
// 코워크 시스템 프롬프트 삽입
var coworkSystem = BuildCoworkSystemPrompt();
if (!string.IsNullOrEmpty(coworkSystem))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = coworkSystem });
_agentLoop.ActiveTab = runTab;
var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token);
sb.Append(response);
assistantMsg.Content = response;
StopAiIconPulse();
_cachedStreamContent = response;
TryPersistConversation(force: true);
// 완료 알림
if (_settings.Settings.Llm.NotifyOnComplete)
Services.NotificationService.Notify("AX Cowork Agent", "코워크 작업이 완료되었습니다.");
}
finally
{
_agentLoop.EventOccurred -= OnAgentEvent;
_agentLoop.UserDecisionCallback = null;
}
}
else if (runTab == "Code")
{
// 워크플로우 분석기 자동 열기
OpenWorkflowAnalyzerIfEnabled();
// 누적 토큰 초기화
_agentCumulativeInputTokens = 0;
_agentCumulativeOutputTokens = 0;
// Code 탭: 에이전트 루프 사용 (Cowork과 동일 패턴)
_agentLoop.EventOccurred += OnAgentEvent;
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
try
{
var codeSystem = BuildCodeSystemPrompt();
if (!string.IsNullOrEmpty(codeSystem))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = codeSystem });
_agentLoop.ActiveTab = runTab;
var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token);
sb.Append(response);
assistantMsg.Content = response;
StopAiIconPulse();
_cachedStreamContent = response;
TryPersistConversation(force: true);
// 완료 알림
if (_settings.Settings.Llm.NotifyOnComplete)
Services.NotificationService.Notify("AX Code Agent", "코드 작업이 완료되었습니다.");
}
finally
{
_agentLoop.EventOccurred -= OnAgentEvent;
_agentLoop.UserDecisionCallback = null;
}
}
else if (_settings.Settings.Llm.Streaming)
{
await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token))
{
sb.Append(chunk);
StopAiIconPulse();
_cachedStreamContent = sb.ToString();
assistantMsg.Content = _cachedStreamContent;
TryPersistConversation();
// UI 스레드에 제어 양보 — DispatcherTimer가 화면 갱신할 수 있도록
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
}
_cachedStreamContent = sb.ToString();
assistantMsg.Content = _cachedStreamContent;
// 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms)
var drainStart = DateTime.UtcNow;
while (_displayedLength < _cachedStreamContent.Length
&& (DateTime.UtcNow - drainStart).TotalMilliseconds < 600)
{
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
}
}
else
{
var response = await _llm.SendAsync(sendMessages, _streamCts.Token);
sb.Append(response);
assistantMsg.Content = response;
}
draftSucceeded = true;
}
catch (OperationCanceledException)
{
if (sb.Length == 0) sb.Append("(취소됨)");
assistantMsg.Content = sb.ToString();
draftCancelled = true;
draftFailure = "사용자가 작업을 중단했습니다.";
}
catch (Exception ex)
{
var errMsg = $"⚠ 오류: {ex.Message}";
sb.Clear(); sb.Append(errMsg);
assistantMsg.Content = errMsg;
AddRetryButton();
draftFailure = ex.Message;
}
finally
{
// 자동 라우팅 오버라이드 해제
if (routeResult != null)
{
_llm.ClearRouteOverride();
UpdateModelLabel();
}
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리
StopRainbowGlow(); // 레인보우 글로우 종료
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_isStreaming = false;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnSend.Visibility = Visibility.Visible;
_streamCts?.Dispose();
_streamCts = null;
_streamRunTab = null;
SetStatusIdle();
}
// 스트리밍 plaintext → 마크다운 렌더링으로 교체
FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg);
AutoScrollIfNeeded();
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
ChatSession?.RememberConversation(originTab, conv.Id);
SyncTabConversationIdsFromSession();
RefreshConversationList();
if (!string.IsNullOrWhiteSpace(queuedDraftId))
{
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
if (draftSucceeded)
_draftQueueProcessor.Complete(session, originTab, queuedDraftId, _storage, _appState.TaskRuns);
else
_draftQueueProcessor.HandleFailure(session, originTab, queuedDraftId, draftFailure, draftCancelled, 3, _storage, _appState.TaskRuns);
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
if (string.Equals(_runningDraftId, queuedDraftId, StringComparison.OrdinalIgnoreCase))
_runningDraftId = null;
}
}
RefreshDraftQueueUi();
if (!draftCancelled && !string.IsNullOrWhiteSpace(queuedDraftId) && string.Equals(originTab, _activeTab, StringComparison.OrdinalIgnoreCase))
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background);
}
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
private string BuildCoworkSystemPrompt()
{
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are AX Copilot Agent. You can read, write, and edit files using the provided tools.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}, {DateTime.Now:dddd}).");
sb.AppendLine("Available skills: excel_create (.xlsx), docx_create (.docx), csv_create (.csv), markdown_create (.md), html_create (.html), script_create (.bat/.ps1), document_review (품질 검증), format_convert (포맷 변환).");
sb.AppendLine("Always explain your plan step by step BEFORE executing tools. After creating files, summarize what was created.");
sb.AppendLine("Do not stop after a single step. Continue autonomously until the request is completed or a concrete blocker (permission denial, missing dependency, hard error) is encountered.");
sb.AppendLine("When adapting external references, rewrite names/structure/comments to AX Copilot style. Avoid clone-like outputs.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above. Never use placeholder or fictional dates.");
sb.AppendLine("IMPORTANT: When asked to create a document with multiple sections (reports, proposals, analyses, etc.), you MUST:");
sb.AppendLine(" 1. First, plan the document: decide the exact sections (headings), their order, and key points for each section based on the topic.");
sb.AppendLine(" 2. Call document_plan with sections_hint = your planned section titles (comma-separated). Example: sections_hint=\"회사 개요, 사업 현황, 재무 분석, SWOT, 전략 제언, 결론\"");
sb.AppendLine(" This ensures the document structure matches YOUR plan, not a generic template.");
sb.AppendLine(" 3. Then immediately call html_create (or docx_create/file_write) using the scaffold from document_plan.");
sb.AppendLine(" 4. Write actual detailed content for EVERY section — no skipping, no placeholders, no minimal content.");
sb.AppendLine(" 5. Do NOT call html_create directly without document_plan for multi-section documents.");
// 문서 품질 검증 루프
sb.AppendLine("\n## Document Quality Review");
sb.AppendLine("After creating any document (html_create, docx_create, excel_create, etc.), you MUST perform a self-review:");
sb.AppendLine("1. Use file_read to read the generated file and verify the content is complete");
sb.AppendLine("2. Check for logical errors: incorrect dates, inconsistent data, missing sections, broken formatting");
sb.AppendLine("3. Verify all requested topics/sections from the user's original request are covered");
sb.AppendLine("4. If issues found, fix them using file_write or file_edit, then re-verify");
sb.AppendLine("5. Report the review result to the user: what was checked and whether corrections were made");
// 문서 포맷 변환 지원
sb.AppendLine("\n## Format Conversion");
sb.AppendLine("When the user requests format conversion (e.g., HTML→Word, Excel→CSV, Markdown→HTML):");
sb.AppendLine("1. Use file_read or document_read to read the source file content");
sb.AppendLine("2. Create a new file in the target format using the appropriate skill (docx_create, html_create, etc.)");
sb.AppendLine("3. Preserve the content structure, formatting, and data as closely as possible");
// 사용자 지정 출력 포맷
var fmt = llm.DefaultOutputFormat;
if (!string.IsNullOrEmpty(fmt) && fmt != "auto")
{
var fmtMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["xlsx"] = "Excel (.xlsx) using excel_create",
["docx"] = "Word (.docx) using docx_create",
["html"] = "HTML (.html) using html_create",
["md"] = "Markdown (.md) using markdown_create",
["csv"] = "CSV (.csv) using csv_create",
};
if (fmtMap.TryGetValue(fmt, out var fmtDesc))
sb.AppendLine($"IMPORTANT: User prefers output format: {fmtDesc}. Use this format unless the user specifies otherwise.");
}
// 디자인 무드 — HTML 문서 생성 시 mood 파라미터로 전달하도록 안내
if (!string.IsNullOrEmpty(_selectedMood) && _selectedMood != "modern")
sb.AppendLine($"When creating HTML documents with html_create, use mood=\"{_selectedMood}\" for the design template.");
else
sb.AppendLine("When creating HTML documents with html_create, you can set 'mood' parameter: modern, professional, creative, minimal, elegant, dark, colorful, corporate, magazine, dashboard.");
if (!string.IsNullOrEmpty(workFolder))
sb.AppendLine($"Current work folder: {workFolder}");
sb.AppendLine($"File permission mode: {llm.FilePermission}");
sb.Append(BuildSubAgentDelegationSection(false));
// 폴더 데이터 활용 지침
switch (_folderDataUsage)
{
case "active":
sb.AppendLine("IMPORTANT: Folder Data Usage = ACTIVE. You have 'document_read' and 'folder_map' tools available.");
sb.AppendLine("Before creating reports, use folder_map to scan the work folder structure. " +
"Then EVALUATE whether each document is RELEVANT to the user's current request topic. " +
"Only use document_read on files that are clearly related to the conversation subject. " +
"Do NOT read or reference files that are unrelated to the user's request, even if they exist in the folder. " +
"In your planning step, list which files you plan to read and explain WHY they are relevant.");
break;
case "passive":
sb.AppendLine("Folder Data Usage = PASSIVE. You have 'document_read' and 'folder_map' tools. " +
"Only read folder documents when the user explicitly asks you to reference or use them.");
break;
default: // "none"
sb.AppendLine("Folder Data Usage = NONE. Do NOT read or reference documents in the work folder unless the user explicitly provides a file path.");
break;
}
// 프리셋 시스템 프롬프트가 있으면 추가
lock (_convLock)
{
if (_currentConversation != null && !string.IsNullOrEmpty(_currentConversation.SystemCommand))
sb.AppendLine("\n" + _currentConversation.SystemCommand);
}
// 프로젝트 문맥 파일 (AGENTS.md) 주입
sb.Append(LoadProjectContext(workFolder));
// 프로젝트 규칙 (.ax/rules/) 자동 주입
sb.Append(BuildProjectRulesSection(workFolder));
// 에이전트 메모리 주입
sb.Append(BuildMemorySection(workFolder));
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
}
private string BuildCodeSystemPrompt()
{
var workFolder = GetCurrentWorkFolder();
var llm = _settings.Settings.Llm;
var code = llm.Code;
var sb = new System.Text.StringBuilder();
sb.AppendLine("You are AX Copilot Code Agent — a senior software engineer for enterprise development.");
sb.AppendLine($"Today's date: {DateTime.Now:yyyy년 M월 d일} ({DateTime.Now:yyyy-MM-dd}).");
sb.AppendLine("Available tools: file_read, file_write, file_edit (supports replace_all), glob, grep (supports context_lines, case_sensitive), folder_map, process, dev_env_detect, build_run, git_tool.");
sb.AppendLine("Do not pause after partial progress. Keep executing consecutive steps until completion or a concrete blocker is reached.");
sb.AppendLine("IMPORTANT: When creating documents with dates, always use today's actual date above.");
sb.AppendLine("\n## Core Workflow (MANDATORY — follow this order)");
sb.AppendLine("1. ORIENT: Run folder_map (depth=2) to understand project structure. Check .gitignore, README, config files.");
sb.AppendLine("2. BASELINE: If tests exist, run build_run action='test' FIRST to establish baseline. Record pass/fail count.");
sb.AppendLine("3. ANALYZE: Use grep (with context_lines=2) + file_read to deeply understand the code you'll modify.");
sb.AppendLine(" - Always check callers/references: grep for function/class names to find all usage points.");
sb.AppendLine(" - Read test files related to the code you're changing to understand expected behavior.");
sb.AppendLine("4. PLAN: Present your analysis + impact assessment. List ALL files that will be modified.");
sb.AppendLine(" - Explain WHY each change is needed and what could break.");
sb.AppendLine(" - If you touch shared services, interfaces, models, controllers, view-models, app startup, or dependency registration, treat it as a high-impact change.");
sb.AppendLine(" - Wait for user approval before proceeding.");
sb.AppendLine("5. IMPLEMENT: Apply changes using file_edit (preferred — shows diff). Use file_write only for new files.");
sb.AppendLine(" - Make the MINIMUM changes needed. Don't refactor unrelated code.");
sb.AppendLine(" - Prefer file_edit with replace_all=false for precision edits.");
sb.AppendLine("6. VERIFY: Run build_run action='build' then action='test'. Compare results with baseline.");
sb.AppendLine(" - If tests fail that passed before, fix immediately.");
sb.AppendLine(" - If build fails, analyze error output and correct.");
sb.AppendLine(" - Re-read every edited file and verify impacted callers/references before finishing.");
sb.AppendLine(" - Do not stop at a passing build if request coverage, edge cases, or changed call sites are still unverified.");
sb.AppendLine(" - For high-impact changes, you must verify both references/callers and build/test evidence before finishing.");
sb.AppendLine("7. GIT: Use git_tool to check status, create diff, and optionally commit.");
sb.AppendLine("8. REPORT: Summarize changes, test results, and any remaining concerns.");
sb.AppendLine("\n## Development Environment");
sb.AppendLine("Use dev_env_detect to check installed IDEs, runtimes, and build tools before running commands.");
sb.AppendLine("IMPORTANT: Do NOT attempt to install compilers, IDEs, or build tools. Only use what is already installed.");
// 패키지 저장소 정보
sb.AppendLine("\n## Package Repositories");
if (!string.IsNullOrEmpty(code.NexusBaseUrl))
sb.AppendLine($"Enterprise Nexus: {code.NexusBaseUrl}");
sb.AppendLine($"NuGet (.NET): {code.NugetSource}");
sb.AppendLine($"PyPI/Conda (Python): {code.PypiSource}");
sb.AppendLine($"Maven (Java): {code.MavenSource}");
sb.AppendLine($"npm (JavaScript): {code.NpmSource}");
sb.AppendLine("When adding dependencies, use these repository URLs.");
// IDE 정보
if (!string.IsNullOrEmpty(code.PreferredIdePath))
sb.AppendLine($"\nPreferred IDE: {code.PreferredIdePath}");
// 사용자 선택 개발 언어
if (_selectedLanguage != "auto")
{
var langName = _selectedLanguage switch { "python" => "Python", "java" => "Java", "csharp" => "C# (.NET)", "cpp" => "C/C++", "javascript" => "JavaScript/TypeScript", _ => _selectedLanguage };
sb.AppendLine($"\nIMPORTANT: User selected language: {langName}. Prioritize this language for code analysis and generation.");
}
// 언어별 가이드라인
sb.AppendLine("\n## Language Guidelines");
sb.AppendLine("- C# (.NET): Use dotnet CLI. NuGet for packages. Follow Microsoft naming conventions.");
sb.AppendLine("- Python: Use conda/pip. Follow PEP8. Use type hints. Virtual env preferred.");
sb.AppendLine("- Java: Use Maven/Gradle. Follow Google Java Style Guide.");
sb.AppendLine("- C++: Use CMake for build. Follow C++ Core Guidelines.");
sb.AppendLine("- JavaScript/TypeScript: Use npm/yarn. Follow ESLint rules. Vue3 uses Composition API.");
// 코드 품질 + 안전 수칙
sb.AppendLine("\n## Code Quality & Safety");
sb.AppendLine("- NEVER delete or overwrite files without user confirmation.");
sb.AppendLine("- ALWAYS read a file before editing it. Don't guess contents.");
sb.AppendLine("- Prefer file_edit over file_write for existing files (shows diff).");
sb.AppendLine("- When porting/referencing external code, do not copy verbatim. Rename and re-structure to match AX Copilot conventions.");
sb.AppendLine("- Use grep to find ALL references before renaming/removing anything.");
sb.AppendLine("- After editing, re-open the changed files and nearby callers to verify the final state, not just the patch intent.");
sb.AppendLine("- Treat verification as incomplete unless you can cite build/test or direct file-read evidence.");
sb.AppendLine("- If unsure about a change's impact, ask the user first.");
sb.AppendLine("- For large refactors, do them incrementally with build verification between steps.");
sb.AppendLine("- Use git_tool action='diff' to review your changes before committing.");
sb.AppendLine("\n## Lint & Format");
sb.AppendLine("After code changes, check for available linters:");
sb.AppendLine("- Python: ruff, black, flake8, pylint");
sb.AppendLine("- JavaScript: eslint, prettier");
sb.AppendLine("- C#: dotnet format");
sb.AppendLine("- C++: clang-format");
sb.AppendLine("Run the appropriate linter via process tool if detected by dev_env_detect.");
if (!string.IsNullOrEmpty(workFolder))
sb.AppendLine($"\nCurrent work folder: {workFolder}");
sb.AppendLine($"File permission mode: {llm.FilePermission}");
sb.Append(BuildSubAgentDelegationSection(true));
// 폴더 데이터 활용
sb.AppendLine("\nFolder Data Usage = ACTIVE. Use folder_map and file_read to understand the codebase.");
sb.AppendLine("Analyze project structure before making changes. Read relevant files to understand context.");
// 프리셋 시스템 프롬프트
lock (_convLock)
{
if (_currentConversation?.SystemCommand is { Length: > 0 } sysCmd)
sb.AppendLine("\n" + sysCmd);
}
// 프로젝트 문맥 파일 (AGENTS.md) 주입
sb.Append(LoadProjectContext(workFolder));
// 프로젝트 규칙 (.ax/rules/) 자동 주입
sb.Append(BuildProjectRulesSection(workFolder));
// 에이전트 메모리 주입
sb.Append(BuildMemorySection(workFolder));
// 피드백 학습 컨텍스트 주입
sb.Append(BuildFeedbackContext());
return sb.ToString();
}
private static string BuildSubAgentDelegationSection(bool codeMode)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n## Sub-Agent Delegation");
sb.AppendLine("Use spawn_agent only for bounded side investigations that can run in parallel with your main work.");
sb.AppendLine("Good delegation targets: impact analysis, reference/caller search, test-file discovery, diff review, and bug root-cause investigation.");
sb.AppendLine("Do not delegate the final editing decision, the final report, or blocking work that you must inspect immediately yourself.");
sb.AppendLine("When spawning a sub-agent, give a concrete task with the exact question, likely file/module scope, and the output shape you want.");
sb.AppendLine("Expected sub-agent result shape: conclusion, files checked, key evidence, recommended next action, risks/unknowns.");
sb.AppendLine("Use wait_agents only when you are ready to integrate the result. Keep doing useful local work while the sub-agent runs.");
if (codeMode)
{
sb.AppendLine("For code tasks, prefer delegating read-only investigations such as caller mapping, related test discovery, or build/test failure triage.");
sb.AppendLine("For high-impact code changes, delegate at least one focused investigation for callers/references or related tests before finalizing.");
}
else
{
sb.AppendLine("For cowork tasks, prefer delegating fact gathering, source cross-checking, or evidence collection while the main agent continues planning.");
}
return sb.ToString();
}
/// <summary>프로젝트 규칙 (.ax/rules/)을 시스템 프롬프트 섹션으로 포맷합니다.</summary>
private string BuildProjectRulesSection(string? workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return "";
if (!_settings.Settings.Llm.EnableProjectRules) return "";
try
{
var rules = Services.Agent.ProjectRulesService.LoadRules(workFolder);
if (rules.Count == 0) return "";
// 컨텍스트별 필터링: Cowork=document, Code=always (기본)
var when = _activeTab == "Code" ? "always" : "always";
var filtered = Services.Agent.ProjectRulesService.FilterRules(rules, when);
return Services.Agent.ProjectRulesService.FormatForSystemPrompt(filtered);
}
catch
{
return "";
}
}
/// <summary>에이전트 메모리를 시스템 프롬프트 섹션으로 포맷합니다.</summary>
private string BuildMemorySection(string? workFolder)
{
if (!_settings.Settings.Llm.EnableAgentMemory) return "";
var app = System.Windows.Application.Current as App;
var memService = app?.MemoryService;
if (memService == null || memService.Count == 0) return "";
// 메모리를 로드 (작업 폴더 변경 시 재로드)
memService.Load(workFolder ?? "");
var all = memService.All;
if (all.Count == 0) return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n## 프로젝트 메모리 (이전 대화에서 학습한 내용)");
sb.AppendLine("아래는 이전 대화에서 학습한 규칙과 선호도입니다. 작업 시 참고하세요.");
sb.AppendLine("새로운 규칙이나 선호도를 발견하면 memory 도구의 save 액션으로 저장하세요.");
sb.AppendLine("사용자가 이전 학습 내용과 다른 지시를 하면 memory 도구의 delete 후 새로 save 하세요.\n");
foreach (var group in all.GroupBy(e => e.Type))
{
var label = group.Key switch
{
"rule" => "프로젝트 규칙",
"preference" => "사용자 선호",
"fact" => "프로젝트 사실",
"correction" => "이전 교정",
_ => group.Key,
};
sb.AppendLine($"[{label}]");
foreach (var e in group.OrderByDescending(e => e.UseCount).Take(15))
sb.AppendLine($"- {e.Content}");
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>워크플로우 시각화 설정이 켜져있으면 분석기 창을 열고 이벤트를 구독합니다.</summary>
private void OpenWorkflowAnalyzerIfEnabled()
{
var llm = _settings.Settings.Llm;
if (!llm.DevMode || !llm.WorkflowVisualizer) return;
if (_analyzerWindow == null)
{
// 새로 생성
_analyzerWindow = new WorkflowAnalyzerWindow();
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
// 테마 리소스 전달
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
_analyzerWindow.Show();
}
else if (!_analyzerWindow.IsVisible)
{
// Hide()로 숨겨진 창 → 기존 내용 유지한 채 다시 표시
_analyzerWindow.Show();
_analyzerWindow.Activate();
}
else
{
// 이미 보이는 상태 → 새 에이전트 실행을 위해 초기화 후 활성화
_analyzerWindow.Reset();
_analyzerWindow.Activate();
}
// 타임라인 탭으로 전환 (새 실행 시작)
_analyzerWindow.SwitchToTimelineTab();
// 이벤트 구독 (중복 방지)
_agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent;
_agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent;
}
/// <summary>워크플로우 분석기 버튼의 표시 상태를 갱신합니다.</summary>
private void UpdateAnalyzerButtonVisibility()
{
var llm = _settings.Settings.Llm;
BtnShowAnalyzer.Visibility = (llm.DevMode && llm.WorkflowVisualizer)
? Visibility.Visible : Visibility.Collapsed;
}
/// <summary>워크플로우 분석기 창을 수동으로 열거나 포커스합니다 (하단 바 버튼).</summary>
private void BtnShowAnalyzer_Click(object sender, MouseButtonEventArgs e)
{
if (_analyzerWindow == null)
{
_analyzerWindow = new WorkflowAnalyzerWindow();
_analyzerWindow.Closed += (_, _) => _analyzerWindow = null;
foreach (var dict in System.Windows.Application.Current.Resources.MergedDictionaries)
_analyzerWindow.Resources.MergedDictionaries.Add(dict);
// 에이전트 이벤트 구독
_agentLoop.EventOccurred -= _analyzerWindow.OnAgentEvent;
_agentLoop.EventOccurred += _analyzerWindow.OnAgentEvent;
_analyzerWindow.Show();
}
else if (!_analyzerWindow.IsVisible)
{
_analyzerWindow.Show();
_analyzerWindow.Activate();
}
else
{
_analyzerWindow.Activate();
}
}
/// <summary>에이전트 루프 동안 누적 토큰 (하단 바 표시용)</summary>
private int _agentCumulativeInputTokens;
private int _agentCumulativeOutputTokens;
private static readonly HashSet<string> WriteToolNames = new(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "html_create", "xlsx_create",
"docx_create", "csv_create", "md_create", "script_create",
"diff_preview", "open_external",
};
private void OnAgentEvent(AgentEvent evt)
{
var eventTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!;
// 에이전트 이벤트를 채팅 UI에 표시 (도구 호출/결과 배너)
if (string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
AddAgentEventBanner(evt);
AppendConversationExecutionEvent(evt, eventTab);
AutoScrollIfNeeded();
// 하단 상태바 업데이트
UpdateStatusBar(evt);
_appState.ApplyAgentEvent(evt);
if (evt.Type == AgentEventType.Complete)
AppendConversationAgentRun(evt, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, eventTab);
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
AppendConversationAgentRun(evt, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, eventTab);
// 하단 바 토큰 누적 업데이트 (에이전트 루프 전체 합계)
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
{
_agentCumulativeInputTokens += evt.InputTokens;
_agentCumulativeOutputTokens += evt.OutputTokens;
UpdateStatusTokens(_agentCumulativeInputTokens, _agentCumulativeOutputTokens);
}
// 스티키 진행률 바 업데이트
UpdateAgentProgressBar(evt);
// 계획 뷰어 단계 갱신
if (evt.StepCurrent > 0 && evt.StepTotal > 0)
UpdatePlanViewerStep(evt);
if (evt.Type == AgentEventType.Complete)
CompletePlanViewer();
// 파일 탐색기 자동 새로고침
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath))
RefreshFileTreeIfVisible();
// suggest_actions 도구 결과 → 후속 작업 칩 표시
if (evt.Type == AgentEventType.ToolResult && evt.ToolName == "suggest_actions" && evt.Success)
RenderSuggestActionChips(evt.Summary);
// 파일 생성/수정 결과가 있으면 미리보기 자동 표시 또는 갱신
if (evt.Success && !string.IsNullOrEmpty(evt.FilePath) &&
(evt.Type == AgentEventType.ToolResult || evt.Type == AgentEventType.Complete) &&
WriteToolNames.Contains(evt.ToolName))
{
var autoPreview = _settings.Settings.Llm.AutoPreview;
if (autoPreview == "auto")
{
// 별도 창 미리보기: 이미 열린 파일이면 새로고침, 아니면 새 탭 추가
if (PreviewWindow.IsOpen)
PreviewWindow.RefreshIfOpen(evt.FilePath);
else
TryShowPreview(evt.FilePath);
// 새 파일이면 항상 표시
if (!PreviewWindow.IsOpen)
TryShowPreview(evt.FilePath);
}
}
UpdateTaskSummaryIndicators();
}
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
{
Dispatcher.Invoke(() =>
{
_appState.ApplySubAgentStatus(evt);
UpdateTaskSummaryIndicators();
});
}
private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab)
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
var normalizedTarget = NormalizeTabName(targetTab);
var normalizedActive = NormalizeTabName(_activeTab);
if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase))
{
_currentConversation = session.AppendAgentRun(normalizedTarget, evt, status, summary, _storage);
return;
}
var activeSnapshot = _currentConversation;
var previousSessionConversation = session.CurrentConversation;
session.AppendAgentRun(normalizedTarget, evt, status, summary, _storage);
if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
{
session.CurrentConversation = activeSnapshot;
_currentConversation = activeSnapshot;
}
else if (previousSessionConversation != null
&& string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
{
session.CurrentConversation = previousSessionConversation;
_currentConversation = previousSessionConversation;
}
else
{
var activeId = session.GetConversationId(normalizedActive);
var activeConv = string.IsNullOrWhiteSpace(activeId) ? null : _storage.Load(activeId);
if (activeConv != null)
{
session.CurrentConversation = activeConv;
_currentConversation = activeConv;
}
else
{
var fallback = session.LoadOrCreateConversation(normalizedActive, _storage, _settings);
session.CurrentConversation = fallback;
_currentConversation = fallback;
}
}
}
}
private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab)
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
var normalizedTarget = NormalizeTabName(targetTab);
var normalizedActive = NormalizeTabName(_activeTab);
if (string.Equals(normalizedTarget, normalizedActive, StringComparison.OrdinalIgnoreCase))
{
_currentConversation = session.AppendExecutionEvent(normalizedTarget, evt, _storage);
return;
}
var activeSnapshot = _currentConversation;
var previousSessionConversation = session.CurrentConversation;
session.AppendExecutionEvent(normalizedTarget, evt, _storage);
if (activeSnapshot != null && string.Equals(NormalizeTabName(activeSnapshot.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
{
session.CurrentConversation = activeSnapshot;
_currentConversation = activeSnapshot;
}
else if (previousSessionConversation != null
&& string.Equals(NormalizeTabName(previousSessionConversation.Tab), normalizedActive, StringComparison.OrdinalIgnoreCase))
{
session.CurrentConversation = previousSessionConversation;
_currentConversation = previousSessionConversation;
}
else
{
var activeId = session.GetConversationId(normalizedActive);
var activeConv = string.IsNullOrWhiteSpace(activeId) ? null : _storage.Load(activeId);
if (activeConv != null)
{
session.CurrentConversation = activeConv;
_currentConversation = activeConv;
}
else
{
var fallback = session.LoadOrCreateConversation(normalizedActive, _storage, _settings);
session.CurrentConversation = fallback;
_currentConversation = fallback;
}
}
}
}
private void SyncAppStateWithCurrentConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
_appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory);
_appState.RestoreRecentTasks(conv?.ExecutionEvents);
ApplyConversationListPreferences(conv);
UpdateTaskSummaryIndicators();
}
private void ApplyConversationListPreferences(ChatConversation? conv)
{
_failedOnlyFilter = false;
_runningOnlyFilter = false;
_sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase);
UpdateConversationFailureFilterUi();
UpdateConversationRunningFilterUi();
UpdateConversationSortUi();
}
private void PersistConversationListPreferences()
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
session.SaveConversationListPreferences(_activeTab, _failedOnlyFilter, _runningOnlyFilter, _sortConversationsByRecent, _storage);
_currentConversation = session.CurrentConversation;
}
}
private void UpdateTaskSummaryIndicators()
{
var status = _appState.GetOperationalStatus(_activeTab);
if (RuntimeActivityBadge != null)
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
? Visibility.Visible
: Visibility.Collapsed;
if (RuntimeActivityLabel != null)
RuntimeActivityLabel.Text = status.RuntimeLabel;
if (LastCompletedLabel != null)
{
LastCompletedLabel.Text = status.LastCompletedText;
LastCompletedLabel.Visibility = status.ShowLastCompleted ? Visibility.Visible : Visibility.Collapsed;
}
if (ConversationStatusStrip != null && ConversationStatusStripLabel != null)
{
if (string.Equals(status.StripKind, "permission_waiting", StringComparison.OrdinalIgnoreCase))
{
ConversationStatusStrip.Visibility = Visibility.Visible;
ConversationStatusStrip.Background = BrushFromHex("#FFF7ED");
ConversationStatusStrip.BorderBrush = BrushFromHex("#FDBA74");
ConversationStatusStripLabel.Foreground = BrushFromHex("#C2410C");
ConversationStatusStripLabel.Text = status.StripText;
}
else if (string.Equals(status.StripKind, "running", StringComparison.OrdinalIgnoreCase))
{
ConversationStatusStrip.Visibility = Visibility.Visible;
ConversationStatusStrip.Background = BrushFromHex("#DBEAFE");
ConversationStatusStrip.BorderBrush = BrushFromHex("#93C5FD");
ConversationStatusStripLabel.Foreground = BrushFromHex("#1D4ED8");
ConversationStatusStripLabel.Text = status.StripText;
}
else if (string.Equals(status.StripKind, "failed_run", StringComparison.OrdinalIgnoreCase)
|| string.Equals(status.StripKind, "permission_denied", StringComparison.OrdinalIgnoreCase))
{
ConversationStatusStrip.Visibility = Visibility.Visible;
ConversationStatusStrip.Background = BrushFromHex("#FEF2F2");
ConversationStatusStrip.BorderBrush = BrushFromHex("#FECACA");
ConversationStatusStripLabel.Foreground = BrushFromHex("#991B1B");
ConversationStatusStripLabel.Text = status.StripText;
}
else if (string.Equals(status.StripKind, "queue", StringComparison.OrdinalIgnoreCase))
{
ConversationStatusStrip.Visibility = Visibility.Visible;
ConversationStatusStrip.Background = BrushFromHex("#F5F3FF");
ConversationStatusStrip.BorderBrush = BrushFromHex("#C4B5FD");
ConversationStatusStripLabel.Foreground = BrushFromHex("#6D28D9");
ConversationStatusStripLabel.Text = status.StripText;
}
else if (string.Equals(status.StripKind, "queue_blocked", StringComparison.OrdinalIgnoreCase))
{
ConversationStatusStrip.Visibility = Visibility.Visible;
ConversationStatusStrip.Background = BrushFromHex("#FFFBEB");
ConversationStatusStrip.BorderBrush = BrushFromHex("#FCD34D");
ConversationStatusStripLabel.Foreground = BrushFromHex("#B45309");
ConversationStatusStripLabel.Text = status.StripText;
}
else if (string.Equals(status.StripKind, "background", StringComparison.OrdinalIgnoreCase))
{
ConversationStatusStrip.Visibility = Visibility.Visible;
ConversationStatusStrip.Background = BrushFromHex("#EFF6FF");
ConversationStatusStrip.BorderBrush = BrushFromHex("#BFDBFE");
ConversationStatusStripLabel.Foreground = BrushFromHex("#1D4ED8");
ConversationStatusStripLabel.Text = status.StripText;
}
else
{
ConversationStatusStrip.Visibility = Visibility.Collapsed;
ConversationStatusStripLabel.Text = "";
}
}
UpdateConversationQuickStripUi();
}
private void UpdateConversationQuickStripUi()
{
if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null
|| BtnQuickRunningFilter == null || BtnQuickHotSort == null)
return;
var hasQuickSignal = _runningConversationCount > 0
|| _spotlightConversationCount > 0;
ConversationQuickStrip.Visibility = hasQuickSignal
? Visibility.Visible
: Visibility.Collapsed;
QuickRunningLabel.Text = _runningConversationCount > 0 ? $"진행 {_runningConversationCount}" : "진행";
QuickHotLabel.Text = _spotlightConversationCount > 0 ? $"활동 {_spotlightConversationCount}" : "활동";
BtnQuickRunningFilter.Background = _runningOnlyFilter ? BrushFromHex("#DBEAFE") : BrushFromHex("#F8FAFC");
BtnQuickRunningFilter.BorderBrush = _runningOnlyFilter ? BrushFromHex("#93C5FD") : BrushFromHex("#E5E7EB");
BtnQuickRunningFilter.BorderThickness = new Thickness(1);
QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC");
BtnQuickHotSort.BorderBrush = !_sortConversationsByRecent ? BrushFromHex("#C4B5FD") : BrushFromHex("#E5E7EB");
BtnQuickHotSort.BorderThickness = new Thickness(1);
QuickHotLabel.Foreground = !_sortConversationsByRecent ? BrushFromHex("#6D28D9") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
}
private static string GetRunStatusLabel(string? status)
=> status switch
{
"completed" => "완료",
"failed" => "실패",
"paused" => "일시중지",
_ => "진행 중",
};
private static string GetTaskStatusLabel(string? status)
=> status switch
{
"completed" => "완료",
"failed" => "실패",
"blocked" => "재시도 대기",
"waiting" => "승인 대기",
"cancelled" => "중단",
_ => "진행 중",
};
private IEnumerable<TaskRunStore.TaskRun> FilterTaskSummaryItems(IEnumerable<TaskRunStore.TaskRun> tasks)
=> _taskSummaryTaskFilter switch
{
"permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)),
"queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)),
"hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)),
"subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)),
"tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)),
_ => tasks,
};
private Border CreateTaskSummaryFilterChip(string key, string label)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase);
var chip = new Border
{
Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"),
BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 0, 5, 5),
Cursor = Cursors.Hand,
Child = new TextBlock
{
Text = label,
FontSize = 10.5,
Foreground = active ? BrushFromHex("#4338CA") : secondaryText,
}
};
chip.MouseLeftButtonUp += (_, _) =>
{
_taskSummaryTaskFilter = key;
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
};
return chip;
}
private static Brush GetRunStatusBrush(string? status)
=> status switch
{
"completed" => BrushFromHex("#166534"),
"failed" => BrushFromHex("#B91C1C"),
"paused" => BrushFromHex("#B45309"),
_ => BrushFromHex("#1D4ED8"),
};
private static string ShortRunId(string? runId)
{
if (string.IsNullOrWhiteSpace(runId))
return "main";
return runId.Length <= 8 ? runId : runId[..8];
}
// ─── Task Decomposition UI ────────────────────────────────────────────
private Border? _planningCard;
private StackPanel? _planStepsPanel;
private ProgressBar? _planProgressBar;
private TextBlock? _planProgressText;
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
private void AddPlanningCard(AgentEvent evt)
{
var steps = evt.Steps!;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#FFFFFF");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC");
var card = new Border
{
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16, 14, 16, 14),
Margin = new Thickness(20, 6, 170, 10),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = GetMessageMaxWidth(),
};
var sp = new StackPanel();
// 헤더
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
header.Children.Add(new TextBlock
{
Text = "\uE9D5", // plan icon
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
header.Children.Add(new TextBlock
{
Text = $"{steps.Count}개의 작업 완료 중 0",
FontSize = 11.5, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
sp.Children.Add(header);
// 진행률 바
var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 8) };
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
_planProgressBar = new ProgressBar
{
Minimum = 0,
Maximum = steps.Count,
Value = 0,
Height = 4,
Foreground = accentBrush,
Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"),
VerticalAlignment = VerticalAlignment.Center,
};
// Remove the default border on ProgressBar
_planProgressBar.BorderThickness = new Thickness(0);
Grid.SetColumn(_planProgressBar, 0);
progressGrid.Children.Add(_planProgressBar);
_planProgressText = new TextBlock
{
Text = "0%",
FontSize = 10.5, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(8, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(_planProgressText, 1);
progressGrid.Children.Add(_planProgressText);
sp.Children.Add(progressGrid);
// 단계 목록
_planStepsPanel = new StackPanel();
for (int i = 0; i < steps.Count; i++)
{
var stepRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 1, 0, 1),
Tag = i, // 인덱스 저장
};
stepRow.Children.Add(new TextBlock
{
Text = "○", // 빈 원 (미완료)
FontSize = 11,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
Tag = "status",
});
stepRow.Children.Add(new TextBlock
{
Text = $"{i + 1}. {steps[i]}",
FontSize = 11.5,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
MaxWidth = Math.Max(320, GetMessageMaxWidth() - 60),
VerticalAlignment = VerticalAlignment.Center,
});
_planStepsPanel.Children.Add(stepRow);
}
sp.Children.Add(_planStepsPanel);
card.Child = sp;
_planningCard = card;
// 페이드인
card.Opacity = 0;
card.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
MessagePanel.Children.Add(card);
}
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> options)
{
var expressionLevel = GetAgentUiExpressionLevel();
var showDetailedCopy = expressionLevel != "simple";
var showRichHint = expressionLevel == "rich";
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var accentColor = ((SolidColorBrush)accentBrush).Color;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var borderBrush = TryFindResource("BorderColor") as Brush
?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B));
var container = new Border
{
Margin = expressionLevel == "simple"
? new Thickness(40, 2, 120, 6)
: new Thickness(40, 2, 80, 6),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = expressionLevel == "simple" ? 460 : 560,
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10, 12, 10),
};
var outerStack = new StackPanel();
outerStack.Children.Add(new TextBlock
{
Text = "실행 계획 승인 요청",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
if (showDetailedCopy)
{
outerStack.Children.Add(new TextBlock
{
Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.",
FontSize = 11.5,
Foreground = secondaryText,
Margin = new Thickness(0, 2, 0, 8),
TextWrapping = TextWrapping.Wrap,
});
}
if (showDetailedCopy && options.Count > 0)
{
var optionCandidates = new List<string>();
foreach (var option in options)
{
if (string.IsNullOrWhiteSpace(option))
continue;
optionCandidates.Add(option.Trim());
if (optionCandidates.Count >= 3)
break;
}
var optionHint = string.Join(" · ", optionCandidates);
if (!string.IsNullOrWhiteSpace(optionHint))
{
outerStack.Children.Add(new TextBlock
{
Text = $"선택지: {optionHint}",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
TextWrapping = TextWrapping.Wrap,
});
}
}
if (showRichHint)
{
outerStack.Children.Add(new TextBlock
{
Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
TextWrapping = TextWrapping.Wrap,
});
}
// 버튼 행
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) };
// 승인 버튼 (강조)
var approveBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(16, 7, 16, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
};
var approveSp = new StackPanel { Orientation = Orientation.Horizontal };
approveSp.Children.Add(new TextBlock
{
Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
approveSp.Children.Add(new TextBlock
{
Text = "승인 후 실행",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White
});
approveBtn.Child = approveSp;
ApplyMenuItemHover(approveBtn);
approveBtn.MouseLeftButtonUp += (_, _) =>
{
CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush);
tcs.TrySetResult(null); // null = 승인
};
btnRow.Children.Add(approveBtn);
// 수정 요청 버튼
var editBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
};
var editSp = new StackPanel { Orientation = Orientation.Horizontal };
editSp.Children.Add(new TextBlock
{
Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush });
editBtn.Child = editSp;
ApplyMenuItemHover(editBtn);
// 수정 요청용 텍스트 입력 패널 (초기 숨김)
var editInputPanel = new Border
{
Visibility = Visibility.Collapsed,
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 8, 0, 0),
};
var editInputStack = new StackPanel();
editInputStack.Children.Add(new TextBlock
{
Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:",
FontSize = 11.5, Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6),
});
var editTextBox = new TextBox
{
MinHeight = 36,
MaxHeight = 100,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 12.5,
Background = itemBg,
Foreground = primaryText,
CaretBrush = primaryText,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 6, 8, 6),
};
editInputStack.Children.Add(editTextBox);
var submitEditBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 5, 12, 5),
Margin = new Thickness(0, 6, 0, 0),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
};
submitEditBtn.Child = new TextBlock
{
Text = "피드백 전송",
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White
};
ApplyHoverScaleAnimation(submitEditBtn, 1.05);
submitEditBtn.MouseLeftButtonUp += (_, _) =>
{
var feedback = editTextBox.Text.Trim();
if (string.IsNullOrEmpty(feedback)) return;
CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush);
tcs.TrySetResult(feedback);
};
editInputStack.Children.Add(submitEditBtn);
editInputPanel.Child = editInputStack;
editBtn.MouseLeftButtonUp += (_, _) =>
{
editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible
? Visibility.Collapsed : Visibility.Visible;
if (editInputPanel.Visibility == Visibility.Visible)
editTextBox.Focus();
};
btnRow.Children.Add(editBtn);
// 취소 버튼
var cancelBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)),
BorderThickness = new Thickness(1),
};
var cancelSp = new StackPanel { Orientation = Orientation.Horizontal };
cancelSp.Children.Add(new TextBlock
{
Text = "\uE711", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
cancelSp.Children.Add(new TextBlock
{
Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
});
cancelBtn.Child = cancelSp;
ApplyMenuItemHover(cancelBtn);
cancelBtn.MouseLeftButtonUp += (_, _) =>
{
CollapseDecisionButtons(outerStack, "✕ 취소됨",
new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)));
tcs.TrySetResult("취소");
};
btnRow.Children.Add(cancelBtn);
outerStack.Children.Add(btnRow);
outerStack.Children.Add(editInputPanel);
container.Child = outerStack;
// 슬라이드 + 페이드 등장 애니메이션
ApplyMessageEntryAnimation(container);
MessagePanel.Children.Add(container);
ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동
// PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기
var capturedOuterStack = outerStack;
var capturedAccent = accentBrush;
_ = tcs.Task.ContinueWith(t =>
{
Dispatcher.BeginInvoke(() =>
{
// 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우)
if (capturedOuterStack.Children.Count <= 1) return;
var label = t.Result == null ? "✓ 승인됨"
: t.Result == "취소" ? "✕ 취소됨"
: "✎ 수정 요청됨";
var fg = t.Result == "취소"
? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
: capturedAccent;
CollapseDecisionButtons(capturedOuterStack, label, fg);
});
}, TaskScheduler.Default);
}
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
{
outerStack.Children.Clear();
var resultLabel = new TextBlock
{
Text = resultText,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = fg,
Opacity = 0.8,
Margin = new Thickness(0, 2, 0, 2),
};
outerStack.Children.Add(resultLabel);
}
// ════════════════════════════════════════════════════════════
// 실행 계획 뷰어 (PlanViewerWindow) 연동
// ════════════════════════════════════════════════════════════
/// <summary>PlanViewerWindow를 사용하는 UserDecisionCallback을 생성합니다.</summary>
private Func<string, List<string>, Task<string?>> CreatePlanDecisionCallback()
{
return async (planSummary, options) =>
{
var tcs = new TaskCompletionSource<string?>();
var steps = Services.Agent.TaskDecomposer.ExtractSteps(planSummary);
await Dispatcher.InvokeAsync(() =>
{
// PlanViewerWindow 생성 또는 재사용
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow))
{
_planViewerWindow = new PlanViewerWindow();
_planViewerWindow.Closing += (_, e) =>
{
e.Cancel = true;
_planViewerWindow.Hide();
};
}
// 계획 표시 + 승인 대기
_planViewerWindow.ShowPlanAsync(planSummary, steps, tcs);
// 채팅 창에 간략 배너 추가 + 인라인 승인 버튼도 표시
AddDecisionButtons(tcs, options);
// 하단 바 계획 버튼 표시
ShowPlanButton(true);
});
// 5분 타임아웃
var completed = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromMinutes(5)));
if (completed != tcs.Task)
{
await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide());
return "취소";
}
var result = await tcs.Task;
var agentDecision = result;
if (result == null)
{
agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix);
}
else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(result, "승인", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(result))
{
agentDecision = $"수정 요청: {result.Trim()}";
}
// 승인된 경우 — 실행 모드로 전환
if (result == null) // null = 승인
{
await Dispatcher.InvokeAsync(() =>
{
_planViewerWindow?.SwitchToExecutionMode();
_planViewerWindow?.Hide(); // 숨기고 하단 버튼으로 다시 열기
});
}
else
{
await Dispatcher.InvokeAsync(() => _planViewerWindow?.Hide());
}
return agentDecision;
};
}
/// <summary>하단 바에 계획 보기 버튼을 표시/숨김합니다.</summary>
private void ShowPlanButton(bool show)
{
if (!show)
{
// 계획 버튼 제거
for (int i = MoodIconPanel.Children.Count - 1; i >= 0; i--)
{
if (MoodIconPanel.Children[i] is Border b && b.Tag?.ToString() == "PlanBtn")
{
// 앞의 구분선도 제거
if (i > 0 && MoodIconPanel.Children[i - 1] is Border sep && sep.Tag?.ToString() == "PlanSep")
MoodIconPanel.Children.RemoveAt(i - 1);
if (i < MoodIconPanel.Children.Count)
MoodIconPanel.Children.RemoveAt(Math.Min(i, MoodIconPanel.Children.Count - 1));
break;
}
}
return;
}
// 이미 있으면 무시
foreach (var child in MoodIconPanel.Children)
{
if (child is Border b && b.Tag?.ToString() == "PlanBtn") return;
}
// 구분선
var separator = new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
Tag = "PlanSep",
};
MoodIconPanel.Children.Add(separator);
// 계획 버튼
var planBtn = CreateFolderBarButton("\uE9D2", "계획", "실행 계획 보기", "#10B981");
planBtn.Tag = "PlanBtn";
planBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
{
_planViewerWindow.Show();
_planViewerWindow.Activate();
}
};
MoodIconPanel.Children.Add(planBtn);
}
/// <summary>계획 뷰어에서 현재 실행 단계를 갱신합니다.</summary>
private void UpdatePlanViewerStep(AgentEvent evt)
{
if (_planViewerWindow == null || !IsWindowAlive(_planViewerWindow)) return;
if (evt.StepCurrent > 0)
_planViewerWindow.UpdateCurrentStep(evt.StepCurrent - 1); // 0-based
}
/// <summary>계획 실행 완료를 뷰어에 알립니다.</summary>
private void CompletePlanViewer()
{
if (_planViewerWindow != null && IsWindowAlive(_planViewerWindow))
_planViewerWindow.MarkComplete();
ShowPlanButton(false);
}
private static bool IsWindowAlive(Window? w)
{
if (w == null) return false;
try { var _ = w.IsVisible; return true; }
catch { return false; }
}
// ════════════════════════════════════════════════════════════
// 후속 작업 제안 칩 (suggest_actions)
// ════════════════════════════════════════════════════════════
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
private void RenderSuggestActionChips(string jsonSummary)
{
// JSON에서 액션 목록 파싱 시도
List<(string label, string command)> actions = new();
try
{
// summary 형식: "label: command" 줄바꿈 구분 또는 JSON
if (jsonSummary.Contains("\"label\""))
{
using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var item in doc.RootElement.EnumerateArray())
{
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label;
if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd));
}
}
}
else
{
// 줄바꿈 형식: "1. label → command"
foreach (var line in jsonSummary.Split('\n'))
{
var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' ');
if (string.IsNullOrEmpty(trimmed)) continue;
var parts = trimmed.Split('→', ':', '—');
if (parts.Length >= 2)
actions.Add((parts[0].Trim(), parts[1].Trim()));
else if (!string.IsNullOrEmpty(trimmed))
actions.Add((trimmed, trimmed));
}
}
}
catch { return; }
if (actions.Count == 0) return;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var container = new Border
{
Margin = new Thickness(40, 4, 40, 8),
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var headerStack = new StackPanel { Margin = new Thickness(0, 0, 0, 6) };
headerStack.Children.Add(new TextBlock
{
Text = "💡 다음 작업 제안:",
FontSize = 12,
Foreground = secondaryText,
});
var chipPanel = new WrapPanel { Margin = new Thickness(0, 2, 0, 0) };
foreach (var (label, command) in actions.Take(5))
{
var capturedCmd = command;
var chip = new Border
{
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 6),
Cursor = Cursors.Hand,
Background = new SolidColorBrush(Color.FromArgb(0x15,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B)),
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40,
((SolidColorBrush)accentBrush).Color.R,
((SolidColorBrush)accentBrush).Color.G,
((SolidColorBrush)accentBrush).Color.B)),
BorderThickness = new Thickness(1),
};
chip.Child = new TextBlock
{
Text = label,
FontSize = 12.5,
Foreground = accentBrush,
FontWeight = FontWeights.SemiBold,
};
chip.MouseEnter += (s, _) => ((Border)s).Opacity = 0.8;
chip.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
chip.MouseLeftButtonUp += (_, _) =>
{
// 칩 패널 제거 후 해당 명령 실행
MessagePanel.Children.Remove(container);
if (capturedCmd.StartsWith("/"))
{
InputBox.Text = capturedCmd + " ";
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
}
else
{
InputBox.Text = capturedCmd;
_ = SendMessageAsync();
}
};
chipPanel.Children.Add(chip);
}
var outerStack = new StackPanel();
outerStack.Children.Add(headerStack);
outerStack.Children.Add(chipPanel);
container.Child = outerStack;
ApplyMessageEntryAnimation(container);
MessagePanel.Children.Add(container);
ForceScrollToEnd();
}
// ════════════════════════════════════════════════════════════
// 피드백 학습 반영 (J)
// ════════════════════════════════════════════════════════════
/// <summary>최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다.</summary>
private string BuildFeedbackContext()
{
try
{
var recentConversations = _storage.LoadAllMeta()
.OrderByDescending(m => m.UpdatedAt)
.Take(20)
.ToList();
var likedPatterns = new List<string>();
var dislikedPatterns = new List<string>();
foreach (var meta in recentConversations)
{
var conv = _storage.Load(meta.Id);
if (conv == null) continue;
foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null))
{
// 첫 50자로 패턴 파악
var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? "";
if (msg.Feedback == "like")
likedPatterns.Add(preview);
else if (msg.Feedback == "dislike")
dislikedPatterns.Add(preview);
}
}
if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0)
return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n[사용자 선호도 참고]");
if (likedPatterns.Count > 0)
{
sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):");
foreach (var p in likedPatterns.Take(5))
sb.AppendLine($" - \"{p}...\"");
}
if (dislikedPatterns.Count > 0)
{
sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):");
foreach (var p in dislikedPatterns.Take(5))
sb.AppendLine($" - \"{p}...\"");
}
sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요.");
return sb.ToString();
}
catch { return ""; }
}
/// <summary>진행률 바와 단계 상태를 업데이트합니다.</summary>
private void UpdateProgressBar(AgentEvent evt)
{
if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null)
return;
var stepIdx = evt.StepCurrent - 1; // 0-based
var total = evt.StepTotal;
// 진행률 바 업데이트
_planProgressBar.Value = evt.StepCurrent;
var pct = (int)((double)evt.StepCurrent / total * 100);
_planProgressText.Text = $"{pct}%";
// 이전 단계 완료 표시 + 현재 단계 강조
for (int i = 0; i < _planStepsPanel.Children.Count; i++)
{
if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2)
{
var statusTb = row.Children[0] as TextBlock;
var textTb = row.Children[1] as TextBlock;
if (statusTb == null || textTb == null) continue;
if (i < stepIdx)
{
// 완료
statusTb.Text = "●";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280"));
}
else if (i == stepIdx)
{
// 현재 진행 중
statusTb.Text = "◉";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B"));
textTb.FontWeight = FontWeights.SemiBold;
}
else
{
// 대기
statusTb.Text = "○";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563"));
textTb.FontWeight = FontWeights.Normal;
}
}
}
}
/// <summary>Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다.</summary>
private static UIElement BuildDiffView(string text)
{
var panel = new StackPanel
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")),
MaxWidth = 520,
};
var diffStarted = false;
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
// diff 헤더 전의 일반 텍스트
if (!diffStarted && !line.StartsWith("--- "))
{
panel.Children.Add(new TextBlock
{
Text = line,
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
FontFamily = new FontFamily("Consolas"),
Margin = new Thickness(0, 0, 0, 1),
});
continue;
}
diffStarted = true;
string bgHex, fgHex;
if (line.StartsWith("---") || line.StartsWith("+++"))
{
bgHex = "#F3F4F6"; fgHex = "#374151";
}
else if (line.StartsWith("@@"))
{
bgHex = "#EFF6FF"; fgHex = "#3B82F6";
}
else if (line.StartsWith("+"))
{
bgHex = "#ECFDF5"; fgHex = "#059669";
}
else if (line.StartsWith("-"))
{
bgHex = "#FEF2F2"; fgHex = "#DC2626";
}
else
{
bgHex = "Transparent"; fgHex = "#6B7280";
}
var tb = new TextBlock
{
Text = line,
FontSize = 10.5,
FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
Padding = new Thickness(4, 1, 4, 1),
};
if (bgHex != "Transparent")
tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex));
panel.Children.Add(tb);
}
return panel;
}
private sealed class ReviewSignalSummary
{
public int P0 { get; init; }
public int P1 { get; init; }
public int P2 { get; init; }
public int P3 { get; init; }
public bool HasFixed { get; init; }
public bool HasUnfixed { get; init; }
public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed;
}
private static ReviewSignalSummary ExtractReviewSignals(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new ReviewSignalSummary();
var source = text!;
var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈");
var hasFixed = ContainsWholeWord(source, "fixed")
|| ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료");
return new ReviewSignalSummary
{
P0 = CountToken(source, "P0"),
P1 = CountToken(source, "P1"),
P2 = CountToken(source, "P2"),
P3 = CountToken(source, "P3"),
HasUnfixed = hasUnfixed,
HasFixed = hasFixed,
};
}
private static int CountToken(string source, string token)
{
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token))
return 0;
var count = 0;
var index = 0;
while (index < source.Length)
{
var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase);
if (hit < 0)
break;
var before = hit == 0 ? ' ' : source[hit - 1];
var afterIndex = hit + token.Length;
var after = afterIndex >= source.Length ? ' ' : source[afterIndex];
if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after))
count++;
index = hit + token.Length;
}
return count;
}
private static bool ContainsAny(string source, params string[] needles)
{
foreach (var needle in needles)
{
if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
return false;
}
private static bool ContainsWholeWord(string source, string token)
{
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token))
return false;
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b";
return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary)
{
if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(toolName) &&
(toolName.Contains("review", StringComparison.OrdinalIgnoreCase) ||
toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(title) &&
title.Contains("review", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(summary) &&
(summary.Contains("P0", StringComparison.OrdinalIgnoreCase) ||
summary.Contains("P1", StringComparison.OrdinalIgnoreCase) ||
summary.Contains("P2", StringComparison.OrdinalIgnoreCase) ||
summary.Contains("P3", StringComparison.OrdinalIgnoreCase)))
return true;
return false;
}
private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex)
{
return new Border
{
Background = BrushFromHex(bgHex),
BorderBrush = BrushFromHex(borderHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Margin = new Thickness(0, 0, 6, 0),
Padding = new Thickness(8, 2, 8, 2),
Child = new TextBlock
{
Text = text,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(fgHex),
}
};
}
private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary)
{
if (!IsReviewContext(kind, toolName, title, summary))
return null;
var signals = ExtractReviewSignals(summary);
if (!signals.HasAny)
return null;
var row = new WrapPanel
{
Margin = new Thickness(0, 6, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
if (signals.P0 > 0)
row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5"));
if (signals.P1 > 0)
row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74"));
if (signals.P2 > 0)
row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A"));
if (signals.P3 > 0)
row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD"));
if (signals.HasFixed)
row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC"));
if (signals.HasUnfixed)
row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5"));
return row.Children.Count == 0 ? null : row;
}
private void AddAgentEventBanner(AgentEvent evt)
{
var logLevel = _settings.Settings.Llm.AgentLogLevel;
// Planning 이벤트는 단계 목록 카드로 별도 렌더링
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
{
AddPlanningCard(evt);
return;
}
// StepStart 이벤트는 진행률 바 업데이트
if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0)
{
UpdateProgressBar(evt);
return;
}
// simple 모드: ToolCall은 건너뜀 (ToolResult만 한 줄로 표시)
if (logLevel == "simple" && evt.Type == AgentEventType.ToolCall)
return;
// 전체 통계 이벤트는 별도 색상 (보라색 계열)
var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats";
var (icon, label, bgHex, fgHex) = isTotalStats
? ("\uE9D2", "전체 통계", "#F3EEFF", "#7C3AED")
: evt.Type switch
{
AgentEventType.Thinking => ("\uE8BD", "분석 중", "#F0F0FF", "#6B7BC4"),
AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true),
AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false),
AgentEventType.PermissionDenied => ("\uE783", "권한 거부", "#FEF2F2", "#DC2626"),
AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary),
AgentEventType.ToolCall => ("\uE8A7", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 실행" : evt.ToolName, "#EEF6FF", "#3B82F6"),
AgentEventType.ToolResult => ("\uE73E", string.IsNullOrWhiteSpace(evt.ToolName) ? "도구 완료" : evt.ToolName, "#EEF9EE", "#16A34A"),
AgentEventType.SkillCall => ("\uE8A5", string.IsNullOrWhiteSpace(evt.ToolName) ? "스킬 실행" : evt.ToolName, "#FFF7ED", "#EA580C"),
AgentEventType.Error => ("\uE783", "오류", "#FEF2F2", "#DC2626"),
AgentEventType.Complete => ("\uE930", "완료", "#F0FFF4", "#15803D"),
AgentEventType.StepDone => ("\uE73E", "단계 완료", "#EEF9EE", "#16A34A"),
AgentEventType.Paused => ("\uE769", "일시정지", "#FFFBEB", "#D97706"),
AgentEventType.Resumed => ("\uE768", "재개", "#ECFDF5", "#059669"),
_ => ("\uE946", "에이전트", "#F5F5F5", "#6B7280"),
};
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex));
var banner = new Border
{
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(0),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(0),
Margin = new Thickness(24, 2, 24, 2),
HorizontalAlignment = HorizontalAlignment.Stretch,
};
if (!string.IsNullOrWhiteSpace(evt.RunId))
_runBannerAnchors[evt.RunId] = banner;
var sp = new StackPanel();
// 헤더: 얇은 실행 줄 형태
var headerGrid = new Grid();
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
// 좌측: 아이콘 + 라벨
var headerLeft = new StackPanel { Orientation = Orientation.Horizontal };
headerLeft.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10.5,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
headerLeft.Children.Add(new TextBlock
{
Text = label,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
Grid.SetColumn(headerLeft, 0);
// 우측: 소요 시간 + 토큰 배지 (항상 우측 끝에 고정)
var headerRight = new StackPanel { Orientation = Orientation.Horizontal };
if (logLevel != "simple" && evt.ElapsedMs > 0)
{
headerRight.Children.Add(new TextBlock
{
Text = evt.ElapsedMs < 1000 ? $"{evt.ElapsedMs}ms" : $"{evt.ElapsedMs / 1000.0:F1}s",
FontSize = 10,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
});
}
if (logLevel != "simple" && (evt.InputTokens > 0 || evt.OutputTokens > 0))
{
var tokenText = evt.InputTokens > 0 && evt.OutputTokens > 0
? $"{evt.InputTokens}→{evt.OutputTokens}t"
: evt.InputTokens > 0 ? $"↑{evt.InputTokens}t" : $"↓{evt.OutputTokens}t";
headerRight.Children.Add(new Border
{
Background = hintBg,
BorderBrush = borderColor,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(6, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = tokenText,
FontSize = 9.5,
Foreground = secondaryText,
FontFamily = new FontFamily("Consolas"),
},
});
}
Grid.SetColumn(headerRight, 1);
headerGrid.Children.Add(headerLeft);
headerGrid.Children.Add(headerRight);
// header 변수를 headerLeft로 설정 (이후 expandIcon 추가 시 사용)
var header = headerLeft;
sp.Children.Add(headerGrid);
// simple 모드: 요약 한 줄만 표시 (본문 로그)
if (logLevel == "simple")
{
if (!string.IsNullOrEmpty(evt.Summary))
{
var shortSummary = evt.Summary.Length > 100
? evt.Summary[..100] + "…"
: evt.Summary;
sp.Children.Add(new TextBlock
{
Text = shortSummary,
FontSize = 10.5,
Foreground = secondaryText,
TextWrapping = TextWrapping.NoWrap,
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(16, 2, 0, 0),
});
}
}
// detailed/debug 모드: 실행 줄 아래에 얕은 설명만 표시
else if (!string.IsNullOrEmpty(evt.Summary))
{
var summaryText = evt.Summary.Length > 180 ? evt.Summary[..180] + "…" : evt.Summary;
sp.Children.Add(new TextBlock
{
Text = summaryText,
FontSize = 10.5,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(16, 2, 0, 0),
});
}
var reviewChipRow = BuildReviewSignalChipRow(
kind: null,
toolName: evt.ToolName,
title: label,
summary: evt.Summary);
if (reviewChipRow != null)
{
reviewChipRow.Margin = new Thickness(16, 4, 0, 0);
sp.Children.Add(reviewChipRow);
}
// debug 모드: ToolInput 파라미터 표시
if (logLevel == "debug" && !string.IsNullOrEmpty(evt.ToolInput))
{
sp.Children.Add(new Border
{
Background = hintBg,
BorderBrush = borderColor,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(16, 4, 0, 0),
Child = new TextBlock
{
Text = evt.ToolInput.Length > 500 ? evt.ToolInput[..500] + "…" : evt.ToolInput,
FontSize = 10,
Foreground = secondaryText,
FontFamily = new FontFamily("Consolas"),
TextWrapping = TextWrapping.Wrap,
},
});
}
// 파일 경로 배너 (AX Agent 스타일)
if (!string.IsNullOrEmpty(evt.FilePath))
{
var pathBorder = new Border
{
Background = hintBg,
BorderBrush = borderColor,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 5, 8, 5),
Margin = new Thickness(16, 4, 0, 0),
};
var pathGrid = new Grid();
pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var left = new StackPanel { Orientation = Orientation.Vertical };
var fileName = System.IO.Path.GetFileName(evt.FilePath);
var dirName = System.IO.Path.GetDirectoryName(evt.FilePath) ?? "";
var topRow = new StackPanel { Orientation = Orientation.Horizontal };
topRow.Children.Add(new TextBlock
{
Text = "\uE8B7", // folder icon
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
topRow.Children.Add(new TextBlock
{
Text = string.IsNullOrWhiteSpace(fileName) ? evt.FilePath : fileName,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#374151")),
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
});
left.Children.Add(topRow);
if (!string.IsNullOrWhiteSpace(dirName))
{
left.Children.Add(new TextBlock
{
Text = dirName,
FontSize = 9.5,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF")),
FontFamily = new FontFamily("Consolas"),
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
});
}
Grid.SetColumn(left, 0);
pathGrid.Children.Add(left);
// 빠른 작업 버튼들
var quickActions = BuildFileQuickActions(evt.FilePath);
Grid.SetColumn(quickActions, 1);
pathGrid.Children.Add(quickActions);
pathBorder.Child = pathGrid;
sp.Children.Add(pathBorder);
}
banner.Child = sp;
// Total Stats 배너 클릭 → 워크플로우 분석기 병목 분석 탭 열기
if (isTotalStats)
{
banner.Cursor = Cursors.Hand;
banner.ToolTip = "클릭하여 병목 분석 보기";
banner.MouseLeftButtonUp += (_, _) =>
{
OpenWorkflowAnalyzerIfEnabled();
_analyzerWindow?.SwitchToBottleneckTab();
_analyzerWindow?.Activate();
};
}
// 페이드인 애니메이션
banner.Opacity = 0;
banner.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
MessagePanel.Children.Add(banner);
}
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
private StackPanel BuildFileQuickActions(string filePath)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(8, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6");
var accentBrush = new SolidColorBrush(accentColor);
Border MakeBtn(string mdlIcon, string tooltip, Action action)
{
var icon = new TextBlock
{
Text = mdlIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = accentBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
var btn = new Border
{
Child = icon,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(4),
Width = 22,
Height = 22,
Margin = new Thickness(0, 0, 2, 0),
Cursor = Cursors.Hand,
ToolTip = tooltip,
};
btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); };
btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
btn.MouseLeftButtonUp += (_, _) => action();
return btn;
}
// 프리뷰 (지원 확장자만)
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
if (_previewableExtensions.Contains(ext))
{
var path1 = filePath;
panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1)));
}
// 외부 열기
var path2 = filePath;
panel.Children.Add(MakeBtn("\uE8A7", "열기", () =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { }
}));
// 폴더 열기
var path3 = filePath;
panel.Children.Add(MakeBtn("\uED25", "폴더", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { }
}));
// 경로 복사
var path4 = filePath;
panel.Children.Add(MakeBtn("\uE8C8", "복사", () =>
{
try { Clipboard.SetText(path4); } catch { }
}));
return panel;
}
// ─── 응답 재생성 ──────────────────────────────────────────────────────
private async Task RegenerateLastAsync()
{
if (_isStreaming) return;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null) return;
conv = _currentConversation;
}
// 마지막 assistant 메시지 제거
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.RemoveLastAssistantMessage(_activeTab, _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
{
conv.Messages.RemoveAt(conv.Messages.Count - 1);
}
}
// UI에서 마지막 AI 응답 제거
if (MessagePanel.Children.Count > 0)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 재전송
await SendRegenerateAsync(conv);
}
/// <summary>"수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.</summary>
private void ShowRetryWithFeedbackInput()
{
if (_isStreaming) return;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var container = new Border
{
Margin = new Thickness(40, 4, 40, 8),
Padding = new Thickness(14, 10, 14, 10),
CornerRadius = new CornerRadius(12),
Background = itemBg,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = "어떻게 수정하면 좋을지 알려주세요:",
FontSize = 12,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6),
});
var textBox = new TextBox
{
MinHeight = 38,
MaxHeight = 80,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 13,
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
Foreground = primaryText,
CaretBrush = primaryText,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(10, 6, 10, 6),
};
stack.Children.Add(textBox);
var btnRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 8, 0, 0),
};
var sendBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(14, 6, 14, 6),
Cursor = Cursors.Hand,
Margin = new Thickness(6, 0, 0, 0),
};
sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, 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;
MessagePanel.Children.Remove(container);
_ = RetryWithFeedbackAsync(feedback);
};
var cancelBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 6, 12, 6),
Cursor = Cursors.Hand,
};
cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText };
cancelBtn.MouseLeftButtonUp += (_, _) => MessagePanel.Children.Remove(container);
btnRow.Children.Add(cancelBtn);
btnRow.Children.Add(sendBtn);
stack.Children.Add(btnRow);
container.Child = stack;
ApplyMessageEntryAnimation(container);
MessagePanel.Children.Add(container);
ForceScrollToEnd();
textBox.Focus();
}
/// <summary>사용자 피드백과 함께 마지막 응답을 재생성합니다.</summary>
private async Task RetryWithFeedbackAsync(string feedback)
{
if (_isStreaming) return;
ChatConversation conv;
lock (_convLock)
{
if (_currentConversation == null) return;
conv = _currentConversation;
}
// 마지막 assistant 메시지 제거
lock (_convLock)
{
if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
conv.Messages.RemoveAt(conv.Messages.Count - 1);
}
// UI에서 마지막 AI 응답 제거
if (MessagePanel.Children.Count > 0)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 피드백을 사용자 메시지로 추가
var feedbackMsg = new ChatMessage
{
Role = "user",
Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요."
};
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(_activeTab, feedbackMsg, _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(feedbackMsg);
}
}
// 피드백 메시지 UI 표시
AddMessageBubble("user", $"[수정 요청] {feedback}", true);
// 재전송
await SendRegenerateAsync(conv);
}
private async Task SendRegenerateAsync(ChatConversation conv)
{
_isStreaming = true;
BtnSend.IsEnabled = false;
BtnSend.Visibility = Visibility.Collapsed;
BtnStop.Visibility = Visibility.Visible;
_streamCts = new CancellationTokenSource();
var assistantMsg = new ChatMessage { Role = "assistant", Content = "" };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(_activeTab, assistantMsg, _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(assistantMsg);
}
}
var streamContainer = CreateStreamingContainer(out var streamText);
MessagePanel.Children.Add(streamContainer);
ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동
var sb = new System.Text.StringBuilder();
_activeStreamText = streamText;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_cursorTimer.Start();
_typingTimer.Start();
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus("에이전트 작업 중...", spinning: true);
try
{
List<ChatMessage> sendMessages;
lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList();
if (!string.IsNullOrEmpty(conv.SystemCommand))
sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand });
await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token))
{
sb.Append(chunk);
StopAiIconPulse();
_cachedStreamContent = sb.ToString();
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
}
_cachedStreamContent = sb.ToString();
assistantMsg.Content = _cachedStreamContent;
// 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms)
var drainStart2 = DateTime.UtcNow;
while (_displayedLength < _cachedStreamContent.Length
&& (DateTime.UtcNow - drainStart2).TotalMilliseconds < 600)
{
await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background);
}
}
catch (OperationCanceledException)
{
if (sb.Length == 0) sb.Append("(취소됨)");
assistantMsg.Content = sb.ToString();
}
catch (Exception ex)
{
var errMsg = $"⚠ 오류: {ex.Message}";
sb.Clear(); sb.Append(errMsg);
assistantMsg.Content = errMsg;
AddRetryButton();
}
finally
{
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
HideStickyProgress(); // 에이전트 프로그레스 바 + 타이머 정리
StopRainbowGlow(); // 레인보우 글로우 종료
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_isStreaming = false;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnSend.Visibility = Visibility.Visible;
_streamCts?.Dispose();
_streamCts = null;
SetStatusIdle();
}
FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg);
AutoScrollIfNeeded();
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
ChatSession?.RememberConversation(conv.Tab ?? _activeTab, conv.Id);
SyncTabConversationIdsFromSession();
RefreshConversationList();
}
/// <summary>메시지 버블의 MaxWidth를 창 너비에 비례하여 계산합니다 (더 넓은 본문 레이아웃).</summary>
private double GetMessageMaxWidth()
{
var scrollWidth = MessageScroll.ActualWidth;
if (scrollWidth < 100) scrollWidth = 700; // 초기화 전 기본값
// 좌우 여백을 더 줄여 본문과 composer가 넓게 보이도록 조정
var maxW = (scrollWidth - 56) * 0.975;
return Math.Clamp(maxW, 620, 1560);
}
private StackPanel CreateStreamingContainer(out TextBlock streamText)
{
var msgMaxWidth = GetMessageMaxWidth();
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(10, 3, 150, 3),
Opacity = 0,
RenderTransform = new TranslateTransform(0, 10)
};
// 컨테이너 페이드인 + 슬라이드 업
container.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
((TranslateTransform)container.RenderTransform).BeginAnimation(
TranslateTransform.YProperty,
new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) };
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var aiIcon = new TextBlock
{
Text = "\uE945", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 8,
Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
VerticalAlignment = VerticalAlignment.Center
};
// AI 아이콘 펄스 애니메이션 (응답 대기 중)
aiIcon.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
{ AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
EasingFunction = new SineEase() });
_activeAiIcon = aiIcon;
Grid.SetColumn(aiIcon, 0);
headerGrid.Children.Add(aiIcon);
var (streamAgentName, _, _) = GetAgentIdentity();
var aiNameTb = new TextBlock
{
Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
};
Grid.SetColumn(aiNameTb, 1);
headerGrid.Children.Add(aiNameTb);
// 실시간 경과 시간 (헤더 우측)
_elapsedLabel = new TextBlock
{
Text = "0s",
FontSize = 9.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
Opacity = 0.5,
};
Grid.SetColumn(_elapsedLabel, 2);
headerGrid.Children.Add(_elapsedLabel);
container.Children.Add(headerGrid);
var streamCard = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(13, 10, 13, 10)
};
streamText = new TextBlock
{
Text = "\u258c",
FontSize = 12.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
TextWrapping = TextWrapping.Wrap,
LineHeight = 20,
};
streamCard.Child = streamText;
container.Children.Add(streamCard);
return container;
}
// ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
{
// 스트리밍 plaintext 카드 제거
if (streamText.Parent is Border streamCard)
container.Children.Remove(streamCard);
else
container.Children.Remove(streamText);
// 마크다운 렌더링
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
mdPanel.Margin = new Thickness(0, 0, 0, 4);
mdPanel.Opacity = 0;
var mdCard = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(13, 10, 13, 10),
Child = mdPanel,
};
container.Children.Add(mdCard);
mdPanel.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
// 액션 버튼 바 + 토큰 표시
var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var capturedContent = finalContent;
var actionBar = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(2, 2, 0, 0),
Opacity = 0
};
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
{
try { Clipboard.SetText(capturedContent); } catch { }
}));
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
AddLinkedFeedbackButtons(actionBar, btnColor, message);
container.Children.Add(actionBar);
container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
var elapsed = DateTime.UtcNow - _streamStartTime;
var elapsedText = elapsed.TotalSeconds < 60
? $"{elapsed.TotalSeconds:0.#}s"
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
var usage = _llm.LastTokenUsage;
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
var isAgentTab = _activeTab is "Cowork" or "Code";
var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
? _agentCumulativeInputTokens
: usage?.PromptTokens ?? 0;
var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
? _agentCumulativeOutputTokens
: usage?.CompletionTokens ?? 0;
if (displayInput > 0 || displayOutput > 0)
{
UpdateStatusTokens(displayInput, displayOutput);
Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput);
}
string tokenText;
if (displayInput > 0 || displayOutput > 0)
tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
else if (usage != null)
tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
else
tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
var metaText = new TextBlock
{
Text = $"{elapsedText} · {tokenText}",
FontSize = 9.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 4, 0, 0),
Opacity = 0.55,
};
container.Children.Add(metaText);
// Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
var chips = ParseSuggestionChips(finalContent);
if (chips.Count > 0)
{
var chipPanel = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 4),
HorizontalAlignment = HorizontalAlignment.Left,
};
foreach (var (num, label) in chips)
{
var chipBorder = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14, 7, 14, 7),
Margin = new Thickness(0, 0, 8, 6),
Cursor = Cursors.Hand,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
chipBorder.Child = new TextBlock
{
Text = $"{num}. {label}",
FontSize = 12.5,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
};
var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
chipBorder.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
};
chipBorder.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
};
var capturedLabel = $"{num}. {label}";
var capturedPanel = chipPanel;
chipBorder.MouseLeftButtonDown += (_, _) =>
{
// 칩 패널 제거 (1회용)
if (capturedPanel.Parent is Panel parent)
parent.Children.Remove(capturedPanel);
// 선택한 옵션을 사용자 메시지로 전송
InputBox.Text = capturedLabel;
_ = SendMessageAsync();
};
chipPanel.Children.Add(chipBorder);
}
container.Children.Add(chipPanel);
}
}
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
private static List<(string Num, string Label)> ParseSuggestionChips(string content)
{
var chips = new List<(string, string)>();
if (string.IsNullOrEmpty(content)) return chips;
var lines = content.Split('\n');
// 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
var candidates = new List<(string, string)>();
var lastBlockStart = -1;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
if (m.Success)
{
if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
{
if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
}
else
{
// 새로운 블록 시작
lastBlockStart = i;
candidates.Clear();
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
}
}
else if (!string.IsNullOrWhiteSpace(line))
{
// 번호 목록이 아닌 줄이 나오면 블록 리셋
lastBlockStart = -1;
candidates.Clear();
}
// 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
}
// 2개 이상 선택지, 10개 이하일 때만 chips로 표시
if (candidates.Count >= 2 && candidates.Count <= 10)
chips.AddRange(candidates);
return chips;
}
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
private static string FormatTokenCount(int count) => count switch
{
>= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
>= 1_000 => $"{count / 1_000.0:0.#}k",
_ => count.ToString(),
};
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
private static int EstimateTokenCount(string text)
{
if (string.IsNullOrEmpty(text)) return 0;
// 한국어 문자 비율에 따라 가중
int cjk = 0;
foreach (var c in text)
if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
}
// ─── 생성 중지 ──────────────────────────────────────────────────────
private void StopGeneration()
{
_streamCts?.Cancel();
}
// ─── 대화 내보내기 ──────────────────────────────────────────────────
// ─── 대화 분기 (Fork) ──────────────────────────────────────────────
private void ForkConversation(
ChatConversation source,
int atIndex,
string? branchHint = null,
string? branchContextMessage = null,
string? branchContextRunId = null)
{
var branchCount = _storage.LoadAllMeta()
.Count(m => m.ParentId == source.Id) + 1;
var fork = ChatSession?.CreateBranchConversation(source, atIndex, branchCount, branchHint, branchContextMessage, branchContextRunId)
?? new ChatConversation
{
Title = source.Title,
Tab = source.Tab,
Category = source.Category,
WorkFolder = source.WorkFolder,
SystemCommand = source.SystemCommand,
ParentId = source.Id,
BranchLabel = $"분기 {branchCount}",
BranchAtIndex = atIndex,
};
try
{
_storage.Save(fork);
ShowToast($"분기 생성: {fork.Title}");
// 분기 대화로 전환
lock (_convLock)
{
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, fork, _storage) ?? fork;
SyncTabConversationIdsFromSession();
}
ChatTitle.Text = fork.Title;
RenderMessages();
RefreshConversationList();
}
catch (Exception ex)
{
ShowToast($"분기 실패: {ex.Message}", "\uE783");
}
}
// ─── 커맨드 팔레트 ─────────────────────────────────────────────────
private void OpenCommandPalette()
{
var palette = new CommandPaletteWindow(ExecuteCommand) { Owner = this };
palette.ShowDialog();
}
private void ExecuteCommand(string commandId)
{
switch (commandId)
{
case "tab:chat": TabChat.IsChecked = true; break;
case "tab:cowork": TabCowork.IsChecked = true; break;
case "tab:code": if (TabCode.IsEnabled) TabCode.IsChecked = true; break;
case "new_conversation": StartNewConversation(); break;
case "search_conversation": ToggleMessageSearch(); break;
case "change_model": BtnModelSelector_Click(this, new RoutedEventArgs()); break;
case "open_settings": BtnSettings_Click(this, new RoutedEventArgs()); break;
case "open_statistics": new StatisticsWindow().Show(); break;
case "change_folder": FolderPathLabel_Click(FolderPathLabel, null!); break;
case "toggle_devmode":
var llm = _settings.Settings.Llm;
llm.DevMode = !llm.DevMode;
_settings.Save();
UpdateAnalyzerButtonVisibility();
ShowToast(llm.DevMode ? "개발자 모드 켜짐" : "개발자 모드 꺼짐");
break;
case "open_audit_log":
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
break;
case "paste_clipboard":
try { var text = Clipboard.GetText(); if (!string.IsNullOrEmpty(text)) InputBox.Text += text; } catch { }
break;
case "export_conversation": ExportConversation(); break;
}
}
private void ExportConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null || conv.Messages.Count == 0) return;
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"{conv.Title}",
DefaultExt = ".md",
Filter = "Markdown (*.md)|*.md|JSON (*.json)|*.json|HTML (*.html)|*.html|PDF 인쇄용 HTML (*.pdf.html)|*.pdf.html|Text (*.txt)|*.txt"
};
if (dlg.ShowDialog() != true) return;
var ext = System.IO.Path.GetExtension(dlg.FileName).ToLowerInvariant();
string content;
if (ext == ".json")
{
content = System.Text.Json.JsonSerializer.Serialize(conv, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
}
else if (dlg.FileName.EndsWith(".pdf.html"))
{
// PDF 인쇄용 HTML — 브라우저에서 자동으로 인쇄 대화상자 표시
content = PdfExportService.BuildHtml(conv);
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
PdfExportService.OpenInBrowser(dlg.FileName);
ShowToast("PDF 인쇄용 HTML이 생성되어 브라우저에서 열렸습니다");
return;
}
else if (ext == ".html")
{
content = ExportToHtml(conv);
}
else
{
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# {conv.Title}");
sb.AppendLine($"_생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}_");
sb.AppendLine();
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
var label = msg.Role == "user" ? "**사용자**" : "**AI**";
sb.AppendLine($"{label} ({msg.Timestamp:HH:mm})");
sb.AppendLine();
sb.AppendLine(msg.Content);
if (msg.AttachedFiles is { Count: > 0 })
{
sb.AppendLine();
sb.AppendLine("_첨부 파일: " + string.Join(", ", msg.AttachedFiles.Select(System.IO.Path.GetFileName)) + "_");
}
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
content = sb.ToString();
}
System.IO.File.WriteAllText(dlg.FileName, content, System.Text.Encoding.UTF8);
}
private static string ExportToHtml(ChatConversation conv)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("<!DOCTYPE html><html><head><meta charset=\"utf-8\">");
sb.AppendLine($"<title>{System.Net.WebUtility.HtmlEncode(conv.Title)}</title>");
sb.AppendLine("<style>body{font-family:'Segoe UI',sans-serif;max-width:800px;margin:0 auto;padding:20px;background:#1a1a2e;color:#e0e0e0}");
sb.AppendLine(".msg{margin:12px 0;padding:12px 16px;border-radius:12px}.user{background:#2d2d5e;margin-left:60px}.ai{background:#1e1e3a;margin-right:60px}");
sb.AppendLine(".meta{font-size:11px;color:#888;margin-bottom:6px}.content{white-space:pre-wrap;line-height:1.6}");
sb.AppendLine("h1{text-align:center;color:#8b6dff}pre{background:#111;padding:12px;border-radius:8px;overflow-x:auto}</style></head><body>");
sb.AppendLine($"<h1>{System.Net.WebUtility.HtmlEncode(conv.Title)}</h1>");
sb.AppendLine($"<p style='text-align:center;color:#888'>생성: {conv.CreatedAt:yyyy-MM-dd HH:mm} · 주제: {conv.Category}</p>");
foreach (var msg in conv.Messages)
{
if (msg.Role == "system") continue;
var cls = msg.Role == "user" ? "user" : "ai";
var label = msg.Role == "user" ? "사용자" : "AI";
sb.AppendLine($"<div class='msg {cls}'>");
sb.AppendLine($"<div class='meta'>{label} · {msg.Timestamp:HH:mm}</div>");
sb.AppendLine($"<div class='content'>{System.Net.WebUtility.HtmlEncode(msg.Content)}</div>");
sb.AppendLine("</div>");
}
sb.AppendLine("</body></html>");
return sb.ToString();
}
// ─── 버튼 이벤트 ──────────────────────────────────────────────────────
private void ChatWindow_KeyDown(object sender, KeyEventArgs e)
{
var mod = Keyboard.Modifiers;
// Ctrl 단축키
if (mod == ModifierKeys.Control)
{
switch (e.Key)
{
case Key.N: BtnNewChat_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.W: Close(); e.Handled = true; break;
case Key.E: ExportConversation(); e.Handled = true; break;
case Key.L: InputBox.Text = ""; InputBox.Focus(); e.Handled = true; break;
case Key.B: BtnToggleSidebar_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.M: BtnModelSelector_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.OemComma: BtnSettings_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.F: ToggleMessageSearch(); e.Handled = true; break;
case Key.K: OpenSidebarSearch(); e.Handled = true; break;
case Key.D1: TabChat.IsChecked = true; e.Handled = true; break;
case Key.D2: TabCowork.IsChecked = true; e.Handled = true; break;
case Key.D3: if (TabCode.IsEnabled) TabCode.IsChecked = true; e.Handled = true; break;
}
}
// Ctrl+Shift 단축키
if (mod == (ModifierKeys.Control | ModifierKeys.Shift))
{
switch (e.Key)
{
case Key.C:
// 마지막 AI 응답 복사
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv != null)
{
var lastAi = conv.Messages.LastOrDefault(m => m.Role == "assistant");
if (lastAi != null)
try { Clipboard.SetText(lastAi.Content); } catch { }
}
e.Handled = true;
break;
case Key.R:
// 마지막 응답 재생성
_ = RegenerateLastAsync();
e.Handled = true;
break;
case Key.D:
// 모든 대화 삭제
BtnDeleteAll_Click(this, new RoutedEventArgs());
e.Handled = true;
break;
case Key.P:
// 커맨드 팔레트
OpenCommandPalette();
e.Handled = true;
break;
}
}
// Escape: 검색 바 닫기 또는 스트리밍 중지
if (e.Key == Key.Escape)
{
if (SidebarSearchEditor?.Visibility == Visibility.Visible) { CloseSidebarSearch(clearText: true); e.Handled = true; }
else if (MessageSearchBar.Visibility == Visibility.Visible) { CloseMessageSearch(); e.Handled = true; }
else if (_isStreaming) { StopGeneration(); e.Handled = true; }
}
// 슬래시 명령 팝업 키 처리
if (TryHandleSlashNavigationKey(e))
return;
if (PermissionPopup.IsOpen && e.Key == Key.Escape)
{
PermissionPopup.IsOpen = false;
e.Handled = true;
}
}
private bool TryHandleSlashNavigationKey(KeyEventArgs e)
{
if (!SlashPopup.IsOpen)
return false;
switch (e.Key)
{
case Key.Escape:
SlashPopup.IsOpen = false;
_slashPalette.SelectedIndex = -1;
e.Handled = true;
return true;
case Key.Up:
SlashPopup_ScrollByDelta(120);
e.Handled = true;
return true;
case Key.Down:
SlashPopup_ScrollByDelta(-120);
e.Handled = true;
return true;
case Key.PageUp:
SlashPopup_ScrollByDelta(600);
e.Handled = true;
return true;
case Key.PageDown:
SlashPopup_ScrollByDelta(-600);
e.Handled = true;
return true;
case Key.Home:
{
var visible = GetVisibleSlashOrderedIndices();
_slashPalette.SelectedIndex = visible.Count > 0 ? visible[0] : GetFirstVisibleSlashIndex(_slashPalette.Matches);
UpdateSlashSelectionVisualState();
EnsureSlashSelectionVisible();
e.Handled = true;
return true;
}
case Key.End:
{
var visible = GetVisibleSlashOrderedIndices();
_slashPalette.SelectedIndex = visible.Count > 0 ? visible[^1] : GetFirstVisibleSlashIndex(_slashPalette.Matches);
UpdateSlashSelectionVisualState();
EnsureSlashSelectionVisible();
e.Handled = true;
return true;
}
case Key.Tab when _slashPalette.SelectedIndex >= 0:
case Key.Enter when _slashPalette.SelectedIndex >= 0:
ExecuteSlashSelectedItem();
e.Handled = true;
return true;
default:
return false;
}
}
private void BtnStop_Click(object sender, RoutedEventArgs e) => StopGeneration();
private void BtnPause_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (_agentLoop.IsPaused)
{
_agentLoop.Resume();
PauseIcon.Text = "\uE769"; // 일시정지 아이콘
BtnPause.ToolTip = "일시정지";
}
else
{
_ = _agentLoop.PauseAsync();
PauseIcon.Text = "\uE768"; // 재생 아이콘
BtnPause.ToolTip = "재개";
}
}
private void BtnExport_Click(object sender, RoutedEventArgs e) => ExportConversation();
// ─── 메시지 내 검색 (Ctrl+F) ─────────────────────────────────────────
private List<int> _searchMatchIndices = new();
private int _searchCurrentIndex = -1;
private void ToggleMessageSearch()
{
if (MessageSearchBar.Visibility == Visibility.Visible)
CloseMessageSearch();
else
{
MessageSearchBar.Visibility = Visibility.Visible;
SearchTextBox.Focus();
SearchTextBox.SelectAll();
}
}
private void CloseMessageSearch()
{
MessageSearchBar.Visibility = Visibility.Collapsed;
SearchTextBox.Text = "";
SearchResultCount.Text = "";
_searchMatchIndices.Clear();
_searchCurrentIndex = -1;
// 하이라이트 제거
ClearSearchHighlights();
}
private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
var query = SearchTextBox.Text.Trim();
if (string.IsNullOrEmpty(query))
{
SearchResultCount.Text = "";
_searchMatchIndices.Clear();
_searchCurrentIndex = -1;
ClearSearchHighlights();
return;
}
// 현재 대화의 메시지에서 검색
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
_searchMatchIndices.Clear();
for (int i = 0; i < conv.Messages.Count; i++)
{
if (conv.Messages[i].Content.Contains(query, StringComparison.OrdinalIgnoreCase))
_searchMatchIndices.Add(i);
}
if (_searchMatchIndices.Count > 0)
{
_searchCurrentIndex = 0;
SearchResultCount.Text = $"1/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
else
{
_searchCurrentIndex = -1;
SearchResultCount.Text = "결과 없음";
}
}
private void SearchPrev_Click(object sender, RoutedEventArgs e)
{
if (_searchMatchIndices.Count == 0) return;
_searchCurrentIndex = (_searchCurrentIndex - 1 + _searchMatchIndices.Count) % _searchMatchIndices.Count;
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
private void SearchNext_Click(object sender, RoutedEventArgs e)
{
if (_searchMatchIndices.Count == 0) return;
_searchCurrentIndex = (_searchCurrentIndex + 1) % _searchMatchIndices.Count;
SearchResultCount.Text = $"{_searchCurrentIndex + 1}/{_searchMatchIndices.Count}";
HighlightSearchResult();
}
private void SearchClose_Click(object sender, RoutedEventArgs e) => CloseMessageSearch();
private void HighlightSearchResult()
{
if (_searchCurrentIndex < 0 || _searchCurrentIndex >= _searchMatchIndices.Count) return;
var msgIndex = _searchMatchIndices[_searchCurrentIndex];
// MessagePanel에서 해당 메시지 인덱스의 자식 요소를 찾아 스크롤
// 메시지 패널의 자식 수가 대화 메시지 수와 정확히 일치하지 않을 수 있으므로
// (배너, 계획카드 등 섞임) BringIntoView로 대략적 위치 이동
if (msgIndex < MessagePanel.Children.Count)
{
var element = MessagePanel.Children[msgIndex] as FrameworkElement;
element?.BringIntoView();
}
else if (MessagePanel.Children.Count > 0)
{
// 범위 밖이면 마지막 자식으로 이동
(MessagePanel.Children[^1] as FrameworkElement)?.BringIntoView();
}
}
private void ClearSearchHighlights()
{
// 현재는 BringIntoView 기반이므로 별도 하이라이트 제거 불필요
}
// ─── 에러 복구 재시도 버튼 ──────────────────────────────────────────────
private void AddRetryButton()
{
Dispatcher.Invoke(() =>
{
var retryBorder = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 8, 12, 8),
Margin = new Thickness(40, 4, 80, 4),
HorizontalAlignment = HorizontalAlignment.Left,
Cursor = System.Windows.Input.Cursors.Hand,
};
var retrySp = new StackPanel { Orientation = Orientation.Horizontal };
retrySp.Children.Add(new TextBlock
{
Text = "\uE72C", FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0),
});
retrySp.Children.Add(new TextBlock
{
Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
VerticalAlignment = VerticalAlignment.Center,
});
retryBorder.Child = retrySp;
retryBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x44, 0x44)); };
retryBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xEF, 0x44, 0x44)); };
retryBorder.MouseLeftButtonUp += (_, _) =>
{
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.RemoveLastAssistantMessage(_activeTab, _storage);
_currentConversation = session.CurrentConversation;
}
else if (_currentConversation != null)
{
var lastIdx = _currentConversation.Messages.Count - 1;
if (lastIdx >= 0 && _currentConversation.Messages[lastIdx].Role == "assistant")
_currentConversation.Messages.RemoveAt(lastIdx);
}
}
_ = RegenerateLastAsync();
};
MessagePanel.Children.Add(retryBorder);
ForceScrollToEnd();
});
}
// ─── 메시지 우클릭 컨텍스트 메뉴 ───────────────────────────────────────
private void ShowMessageContextMenu(string content, string role)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
var (popup, panel) = CreateThemedPopupMenu();
// 복사
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8C8", "텍스트 복사", secondaryText, primaryText, hoverBg, () =>
{
try { Clipboard.SetText(content); ShowToast("복사되었습니다"); } catch { }
}));
// 마크다운 복사
panel.Children.Add(CreatePopupMenuItem(popup, "\uE943", "마크다운 복사", secondaryText, primaryText, hoverBg, () =>
{
try { Clipboard.SetText(content); ShowToast("마크다운으로 복사됨"); } catch { }
}));
// 인용하여 답장
panel.Children.Add(CreatePopupMenuItem(popup, "\uE97A", "인용하여 답장", secondaryText, primaryText, hoverBg, () =>
{
var quote = content.Length > 200 ? content[..200] + "..." : content;
var lines = quote.Split('\n');
var quoted = string.Join("\n", lines.Select(l => $"> {l}"));
InputBox.Text = quoted + "\n\n";
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text.Length;
}));
AddPopupMenuSeparator(panel, borderBrush);
// 재생성 (AI 응답만)
if (role == "assistant")
{
panel.Children.Add(CreatePopupMenuItem(popup, "\uE72C", "응답 재생성", secondaryText, primaryText, hoverBg, () => _ = RegenerateLastAsync()));
}
// 대화 분기 (Fork)
panel.Children.Add(CreatePopupMenuItem(popup, "\uE8A5", "여기서 분기", secondaryText, primaryText, hoverBg, () =>
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
var idx = conv.Messages.FindLastIndex(m => m.Role == role && m.Content == content);
if (idx < 0) return;
ForkConversation(conv, idx);
}));
AddPopupMenuSeparator(panel, borderBrush);
// 이후 메시지 모두 삭제
var msgContent = content;
var msgRole = role;
panel.Children.Add(CreatePopupMenuItem(popup, "\uE74D", "이후 메시지 모두 삭제", dangerBrush, dangerBrush, hoverBg, () =>
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
var idx = conv.Messages.FindLastIndex(m => m.Role == msgRole && m.Content == msgContent);
if (idx < 0) return;
var removeCount = conv.Messages.Count - idx;
if (CustomMessageBox.Show($"이 메시지 포함 {removeCount}개 메시지를 삭제하시겠습니까?",
"메시지 삭제", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
return;
conv.Messages.RemoveRange(idx, removeCount);
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
RenderMessages();
ShowToast($"{removeCount}개 메시지 삭제됨");
}));
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; }, DispatcherPriority.Input);
}
// ─── 팁 알림 ──────────────────────────────────────────────────────
private static readonly string[] Tips =
[
"💡 작업 폴더에 AGENTS.md 파일을 만들면 매번 시스템 프롬프트에 자동 주입됩니다. 프로젝트 설계 원칙이나 코딩 규칙을 기록하세요.",
"💡 Ctrl+1/2/3으로 Chat/Cowork/Code 탭을 빠르게 전환할 수 있습니다.",
"💡 Ctrl+F로 현재 대화 내 메시지를 검색할 수 있습니다.",
"💡 메시지를 우클릭하면 복사, 인용 답장, 재생성, 삭제를 할 수 있습니다.",
"💡 코드 블록을 더블클릭하면 전체화면으로 볼 수 있고, 💾 버튼으로 파일 저장이 가능합니다.",
"💡 Cowork 에이전트가 만든 파일은 자동으로 날짜_시간 접미사가 붙어 덮어쓰기를 방지합니다.",
"💡 Code 탭에서 개발 언어를 선택하면 해당 언어 우선으로 코드를 생성합니다.",
"💡 파일 탐색기(하단 바 '파일' 버튼)에서 더블클릭으로 프리뷰, 우클릭으로 관리할 수 있습니다.",
"💡 에이전트가 계획을 제시하면 '수정 요청'으로 방향을 바꾸거나 '취소'로 중단할 수 있습니다.",
"💡 Code 탭은 빌드/테스트를 자동으로 실행합니다. 프로젝트 폴더를 먼저 선택하세요.",
"💡 무드 갤러리에서 10가지 디자인 템플릿 중 원하는 스타일을 미리보기로 선택할 수 있습니다.",
"💡 Git 연동: Code 탭에서 에이전트가 git status, diff, commit을 수행합니다. (push는 직접)",
"💡 설정 → AX Agent → 공통에서 개발자 모드를 켜면 에이전트 동작을 스텝별로 검증할 수 있습니다.",
"💡 트레이 아이콘 우클릭 → '사용 통계'에서 대화 빈도와 토큰 사용량을 확인할 수 있습니다.",
"💡 대화 제목을 클릭하면 이름을 변경할 수 있습니다.",
"💡 LLM 오류 발생 시 '재시도' 버튼이 자동으로 나타납니다.",
"💡 검색란에서 대화 제목뿐 아니라 첫 메시지 내용까지 검색됩니다.",
"💡 프리셋 선택 후에도 대화가 리셋되지 않습니다. 진행 중인 대화에서 프리셋을 변경할 수 있습니다.",
"💡 Shift+Enter로 퍼지 검색 결과의 파일이 있는 폴더를 열 수 있습니다.",
"💡 최근 폴더를 우클릭하면 '폴더 열기', '경로 복사', '목록에서 삭제'가 가능합니다.",
"💡 Cowork/Code 에이전트 작업 완료 시 시스템 트레이에 알림이 표시됩니다.",
"💡 마크다운 테이블, 인용(>), 취소선(~~), 링크([text](url)) .",
"💡 ⚠ 데이터 폴더를 워크스페이스로 지정할 때는 반드시 백업을 먼저 만드세요!",
"💡 드라이브 루트(C:\\, D:\\)는 작업공간으로 설정할 수 없습니다. 하위 폴더를 선택하세요.",
];
private int _tipIndex;
private DispatcherTimer? _tipDismissTimer;
private void ShowRandomTip()
{
if (!_settings.Settings.Llm.ShowTips) return;
if (_activeTab != "Cowork" && _activeTab != "Code") return;
var tip = Tips[_tipIndex % Tips.Length];
_tipIndex++;
// 토스트 스타일로 표시 (기존 토스트와 다른 위치/색상)
ShowTip(tip);
}
private void ShowTip(string message)
{
_tipDismissTimer?.Stop();
ToastText.Text = message;
ToastIcon.Text = "\uE82F"; // 전구 아이콘
ToastBorder.Visibility = Visibility.Visible;
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
var duration = _settings.Settings.Llm.TipDurationSeconds;
if (duration <= 0) return; // 0이면 수동 닫기 (자동 사라짐 없음)
_tipDismissTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(duration) };
_tipDismissTimer.Tick += (_, _) =>
{
_tipDismissTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
_tipDismissTimer.Start();
}
// ─── 프로젝트 문맥 파일 (AGENTS.md) ──────────────────────────────────
/// <summary>
/// 작업 폴더에 AGENTS.md가 있으면 내용을 읽어 시스템 프롬프트에 주입합니다.
/// 프로젝트 로컬 컨텍스트 규약 파일(AGENTS.md) 형식을 사용합니다.
/// </summary>
private static string LoadProjectContext(string workFolder)
{
if (string.IsNullOrEmpty(workFolder)) return "";
// AGENTS.md 탐색 (작업 폴더 → 상위 폴더 순, 레거시 AX.md 폴백)
var searchDir = workFolder;
for (int i = 0; i < 3; i++) // 최대 3단계 상위까지
{
if (string.IsNullOrEmpty(searchDir)) break;
var agentsPath = System.IO.Path.Combine(searchDir, "AGENTS.md");
var legacyPath = System.IO.Path.Combine(searchDir, "AX.md");
var filePath = System.IO.File.Exists(agentsPath) ? agentsPath : legacyPath;
if (System.IO.File.Exists(filePath))
{
try
{
var content = System.IO.File.ReadAllText(filePath);
if (content.Length > 8000) content = content[..8000] + "\n... (8000자 초과 생략)";
var sourceName = System.IO.Path.GetFileName(filePath);
return $"\n## Project Context (from {sourceName})\n{content}\n";
}
catch { }
}
searchDir = System.IO.Directory.GetParent(searchDir)?.FullName;
}
return "";
}
// ─── 무지개 글로우 애니메이션 ─────────────────────────────────────────
private DispatcherTimer? _rainbowTimer;
private DateTime _rainbowStartTime;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
private void PlayRainbowGlow()
{
if (!_settings.Settings.Llm.EnableChatRainbowGlow) return;
_rainbowTimer?.Stop();
_rainbowStartTime = DateTime.UtcNow;
// 페이드인 (빠르게)
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 0.9, TimeSpan.FromMilliseconds(150)));
// 그라데이션 회전 타이머 (~60fps) — 스트리밍 종료까지 지속
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
_rainbowTimer.Tick += (_, _) =>
{
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
// 그라데이션 오프셋 회전
var shift = (elapsed / 1500.0) % 1.0; // 1.5초에 1바퀴 (느리게)
var brush = InputGlowBorder.BorderBrush as LinearGradientBrush;
if (brush == null) return;
// 시작/끝점 회전 (원형 이동)
var angle = shift * Math.PI * 2;
brush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(angle), 0.5 + 0.5 * Math.Sin(angle));
brush.EndPoint = new Point(0.5 - 0.5 * Math.Cos(angle), 0.5 - 0.5 * Math.Sin(angle));
};
_rainbowTimer.Start();
}
/// <summary>레인보우 글로우 효과를 페이드아웃하며 중지합니다.</summary>
private void StopRainbowGlow()
{
_rainbowTimer?.Stop();
_rainbowTimer = null;
if (InputGlowBorder.Opacity > 0)
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0;
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────
private DispatcherTimer? _toastHideTimer;
private void ShowToast(string message, string icon = "\uE73E", int durationMs = 2000)
{
_toastHideTimer?.Stop();
ToastText.Text = message;
ToastIcon.Text = icon;
ToastBorder.Visibility = Visibility.Visible;
// 페이드인
ToastBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(200)));
// 자동 숨기기
_toastHideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(durationMs) };
_toastHideTimer.Tick += (_, _) =>
{
_toastHideTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) => ToastBorder.Visibility = Visibility.Collapsed;
ToastBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
_toastHideTimer.Start();
}
// ─── 대화 주제 버튼 ──────────────────────────────────────────────────
/// <summary>프리셋에서 대화 주제 버튼을 동적으로 생성합니다.</summary>
private void BuildTopicButtons()
{
TopicButtonPanel.Children.Clear();
TopicButtonPanel.Visibility = Visibility.Visible;
// 탭별 EmptyState 텍스트
if (_activeTab == "Cowork" || _activeTab == "Code")
{
if (EmptyStateTitle != null) EmptyStateTitle.Text = "작업 유형을 선택하세요";
if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code"
? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다"
: "에이전트가 상세한 데이터를 작성합니다";
}
else
{
if (EmptyStateTitle != null) EmptyStateTitle.Text = "대화 주제를 선택하세요";
if (EmptyStateDesc != null) EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다";
}
var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets);
foreach (var preset in presets)
{
var capturedPreset = preset;
var btnColor = BrushFromHex(preset.Color);
var border = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 14, 14, 14),
Margin = new Thickness(6, 6, 6, 10),
Cursor = Cursors.Hand,
Width = 124,
Height = 108,
ClipToBounds = true,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
// 아이콘 컨테이너 (원형 배경 + 펄스 애니메이션)
var iconCircle = new Border
{
Width = 40, Height = 40,
CornerRadius = new CornerRadius(20),
Background = new SolidColorBrush(((SolidColorBrush)btnColor).Color) { Opacity = 0.15 },
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 12),
};
var iconTb = new TextBlock
{
Text = preset.Symbol,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = btnColor,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
iconCircle.Child = iconTb;
stack.Children.Add(iconCircle);
// 제목
stack.Children.Add(new TextBlock
{
Text = preset.Label,
FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
});
// 설명
stack.Children.Add(new TextBlock
{
Text = preset.Description,
FontSize = 9, TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxHeight = 28,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 2, 0, 0),
TextAlignment = TextAlignment.Center,
});
// 커스텀 프리셋: 좌측 상단 뱃지
if (capturedPreset.IsCustom)
{
var grid = new Grid();
grid.Children.Add(stack);
var badge = new Border
{
Width = 16, Height = 16,
CornerRadius = new CornerRadius(4),
Background = new SolidColorBrush(Color.FromArgb(0x60, 0xFF, 0xFF, 0xFF)),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(2, 2, 0, 0),
ToolTip = "커스텀 프리셋",
};
badge.Child = new TextBlock
{
Text = "\uE710", // + 아이콘
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 8,
Foreground = btnColor,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
grid.Children.Add(badge);
border.Child = grid;
}
else
{
border.Child = stack;
}
// 호버 애니메이션 — 스케일 1.05x + 밝기 변경
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var normalBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
border.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{
st.ScaleX = 1.03; st.ScaleY = 1.03;
b.Background = hoverBg;
}
};
border.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{
st.ScaleX = 1.0; st.ScaleY = 1.0;
b.Background = normalBg;
}
};
// 클릭 → 해당 주제로 새 대화 시작
border.MouseLeftButtonDown += (_, _) => SelectTopic(capturedPreset);
// 커스텀 프리셋: 우클릭 메뉴 (편집/삭제)
if (capturedPreset.IsCustom)
{
border.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
ShowCustomPresetContextMenu(s as Border, capturedPreset);
};
}
TopicButtonPanel.Children.Add(border);
}
// "기타" 자유 입력 버튼 추가
{
var etcColor = BrushFromHex("#6B7280"); // 회색
var etcBorder = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 14, 14, 14),
Margin = new Thickness(6, 6, 6, 10),
Cursor = Cursors.Hand,
Width = 124,
Height = 108,
ClipToBounds = true,
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
var etcStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
var etcIconCircle = new Border
{
Width = 40, Height = 40,
CornerRadius = new CornerRadius(20),
Background = new SolidColorBrush(((SolidColorBrush)etcColor).Color) { Opacity = 0.15 },
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 12),
};
etcIconCircle.Child = new TextBlock
{
Text = "\uE70F", // Edit 아이콘
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 18,
Foreground = etcColor,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
etcStack.Children.Add(etcIconCircle);
etcStack.Children.Add(new TextBlock
{
Text = "기타",
FontSize = 13, FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
});
etcStack.Children.Add(new TextBlock
{
Text = "프리셋 없이 자유롭게 대화합니다",
FontSize = 9, TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxHeight = 28,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 2, 0, 0),
TextAlignment = TextAlignment.Center,
});
etcBorder.Child = etcStack;
var hoverBg2 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
var normalBg2 = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
etcBorder.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg2; }
};
etcBorder.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = normalBg2; }
};
etcBorder.MouseLeftButtonDown += (_, _) =>
{
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
};
TopicButtonPanel.Children.Add(etcBorder);
}
// ── "+" 커스텀 프리셋 추가 버튼 ──
{
var addColor = BrushFromHex("#6366F1");
var addBorder = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 14, 14, 14),
Margin = new Thickness(6, 6, 6, 10),
Cursor = Cursors.Hand,
Width = 124, Height = 108,
ClipToBounds = true,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(1.5),
RenderTransformOrigin = new Point(0.5, 0.5),
RenderTransform = new ScaleTransform(1, 1),
};
// 점선 효과를 위한 Dashes
if (addBorder.BorderBrush is SolidColorBrush scb)
{
var dashPen = new Pen(scb, 1.5) { DashStyle = DashStyles.Dash };
}
var addStack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
// + 아이콘
var plusIcon = new TextBlock
{
Text = "\uE710",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 24,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 8, 0, 8),
};
addStack.Children.Add(plusIcon);
addStack.Children.Add(new TextBlock
{
Text = "프리셋 추가",
FontSize = 12,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
HorizontalAlignment = HorizontalAlignment.Center,
});
addBorder.Child = addStack;
var hoverBg3 = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
addBorder.MouseEnter += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.03; st.ScaleY = 1.03; b.Background = hoverBg3; }
};
addBorder.MouseLeave += (s, _) =>
{
if (s is Border b && b.RenderTransform is ScaleTransform st)
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = Brushes.Transparent; }
};
addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog();
TopicButtonPanel.Children.Add(addBorder);
}
}
// ─── 커스텀 프리셋 관리 ─────────────────────────────────────────────
/// <summary>커스텀 프리셋 추가 다이얼로그를 표시합니다.</summary>
private void ShowCustomPresetDialog(Models.CustomPresetEntry? existing = null)
{
bool isEdit = existing != null;
var dlg = new CustomPresetDialog(
existingName: existing?.Label ?? "",
existingDesc: existing?.Description ?? "",
existingPrompt: existing?.SystemPrompt ?? "",
existingColor: existing?.Color ?? "#6366F1",
existingSymbol: existing?.Symbol ?? "\uE713",
existingTab: existing?.Tab ?? _activeTab)
{
Owner = this,
};
if (dlg.ShowDialog() == true)
{
if (isEdit)
{
existing!.Label = dlg.PresetName;
existing.Description = dlg.PresetDescription;
existing.SystemPrompt = dlg.PresetSystemPrompt;
existing.Color = dlg.PresetColor;
existing.Symbol = dlg.PresetSymbol;
existing.Tab = dlg.PresetTab;
}
else
{
_settings.Settings.Llm.CustomPresets.Add(new Models.CustomPresetEntry
{
Label = dlg.PresetName,
Description = dlg.PresetDescription,
SystemPrompt = dlg.PresetSystemPrompt,
Color = dlg.PresetColor,
Symbol = dlg.PresetSymbol,
Tab = dlg.PresetTab,
});
}
_settings.Save();
BuildTopicButtons();
}
}
/// <summary>커스텀 프리셋 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
private void ShowCustomPresetContextMenu(Border? anchor, Services.TopicPreset preset)
{
if (anchor == null || preset.CustomId == null) return;
var popup = new System.Windows.Controls.Primitives.Popup
{
PlacementTarget = anchor,
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
StaysOpen = false,
AllowsTransparency = true,
};
var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var menuBorder = new Border
{
Background = menuBg,
CornerRadius = new CornerRadius(10),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(4),
MinWidth = 120,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
// 편집 버튼
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
editItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var entry = _settings.Settings.Llm.CustomPresets.FirstOrDefault(c => c.Id == preset.CustomId);
if (entry != null) ShowCustomPresetDialog(entry);
};
stack.Children.Add(editItem);
// 삭제 버튼
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
deleteItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var result = CustomMessageBox.Show(
$"'{preset.Label}' 프리셋을 삭제하시겠습니까?",
"프리셋 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
{
_settings.Settings.Llm.CustomPresets.RemoveAll(c => c.Id == preset.CustomId);
_settings.Save();
BuildTopicButtons();
}
};
stack.Children.Add(deleteItem);
menuBorder.Child = stack;
popup.Child = menuBorder;
popup.IsOpen = true;
}
/// <summary>컨텍스트 메뉴 항목을 생성합니다.</summary>
private Border CreateContextMenuItem(string icon, string label, Brush fg, Brush secondaryFg)
{
var item = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 6, 14, 6),
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = fg,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 13, Foreground = fg,
VerticalAlignment = VerticalAlignment.Center,
});
item.Child = sp;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
item.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
item.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
return item;
}
/// <summary>대화 주제 선택 — 프리셋 시스템 프롬프트 + 카테고리 적용.</summary>
private void SelectTopic(Services.TopicPreset preset)
{
bool hasMessages;
lock (_convLock) hasMessages = _currentConversation?.Messages.Count > 0;
// 입력란에 텍스트가 있으면 기존 대화를 유지 (입력 내용 보존)
bool hasInput = !string.IsNullOrEmpty(InputBox.Text);
bool keepConversation = hasMessages || hasInput;
if (!keepConversation)
{
// 메시지도 입력 텍스트도 없으면 새 대화 시작
StartNewConversation();
}
// 프리셋 적용 (기존 대화에도 프리셋 변경 가능)
lock (_convLock)
{
if (_currentConversation != null)
{
var session = ChatSession;
if (session != null)
{
_currentConversation = session.UpdateConversationMetadata(_activeTab, c =>
{
c.SystemCommand = preset.SystemPrompt;
c.Category = preset.Category;
}, _storage);
}
else
{
_currentConversation.SystemCommand = preset.SystemPrompt;
_currentConversation.Category = preset.Category;
}
}
}
UpdateCategoryLabel();
SaveConversationSettings();
RefreshConversationList();
if (EmptyState != null)
EmptyState.Visibility = Visibility.Collapsed;
InputBox.Focus();
if (!string.IsNullOrEmpty(preset.Placeholder))
{
_promptCardPlaceholder = preset.Placeholder;
if (!keepConversation) ShowPlaceholder();
}
if (keepConversation)
ShowToast($"프리셋 변경: {preset.Label}");
// Cowork 탭: 하단 바 갱신
if (_activeTab == "Cowork")
BuildBottomBar();
}
/// <summary>선택된 디자인 무드 키 (HtmlSkill에서 사용).</summary>
private string _selectedMood = null!; // Loaded 이벤트에서 초기화
private string _selectedLanguage = "auto"; // Code 탭 개발 언어
private string _folderDataUsage = null!; // Loaded 이벤트에서 초기화
/// <summary>하단 바를 구성합니다 (Cowork 작업 제어 중심).</summary>
private void BuildBottomBar()
{
MoodIconPanel.Children.Clear();
// ── 파일 탐색기 토글 버튼 ──
var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706");
fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); };
MoodIconPanel.Children.Add(fileBrowserBtn);
// ── 실행 이력 상세도 버튼 ──
AppendLogLevelButton();
// 구분선 표시
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
}
/// <summary>Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심.</summary>
private void BuildCodeBottomBar()
{
MoodIconPanel.Children.Clear();
var localBtn = CreateFolderBarButton("\uED25", "로컬", "원본 워크스페이스로 전환", "#6B7280");
localBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true;
var currentFolder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder))
{
ShowFolderMenu();
return;
}
var root = WorktreeStateStore.ResolveRoot(currentFolder);
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
ShowFolderMenu();
return;
}
SwitchToWorkspace(root, root);
};
MoodIconPanel.Children.Add(localBtn);
var worktreeBtn = CreateFolderBarButton("\uE8B7", "워크트리", "분리된 작업 복사본 생성 또는 전환", "#2563EB");
worktreeBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowWorktreeMenu(worktreeBtn); };
MoodIconPanel.Children.Add(worktreeBtn);
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible;
}
private string GetWorktreeModeLabel()
{
var folder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
return "로컬";
var root = WorktreeStateStore.ResolveRoot(folder);
var active = WorktreeStateStore.Load(root).Active;
return string.Equals(Path.GetFullPath(active), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)
? "로컬"
: "워크트리";
}
private List<string> GetAvailableWorkspaceVariants(string root, string? active)
{
var variants = new List<string>();
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
return variants;
try
{
var parent = Directory.GetParent(root)?.FullName ?? root;
var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-wt-*"));
variants.AddRange(Directory.GetDirectories(parent, $"{repoName}-copy-*"));
}
catch
{
// ignore discovery failures
}
if (!string.IsNullOrWhiteSpace(active) && Directory.Exists(active))
variants.Add(active);
return variants
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Where(path => !string.Equals(Path.GetFullPath(path), Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(path => string.Equals(Path.GetFullPath(path), Path.GetFullPath(active ?? ""), StringComparison.OrdinalIgnoreCase))
.ThenByDescending(path => Directory.GetLastWriteTime(path))
.Take(8)
.ToList();
}
private void ShowWorktreeMenu(UIElement placementTarget)
{
var (popup, panel) = CreateThemedPopupMenu(placementTarget, PlacementMode.Top, 320);
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var currentFolder = GetCurrentWorkFolder();
var root = string.IsNullOrWhiteSpace(currentFolder) ? "" : WorktreeStateStore.ResolveRoot(currentFolder);
var active = string.IsNullOrWhiteSpace(root) ? currentFolder : WorktreeStateStore.Load(root).Active;
var variants = GetAvailableWorkspaceVariants(root, active);
panel.Children.Add(CreatePopupSummaryStrip(new[]
{
("모드", string.Equals(active, root, StringComparison.OrdinalIgnoreCase) ? "로컬" : "워크트리", "#F8FAFC", "#E2E8F0", "#475569"),
("변형", variants.Count.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"),
}));
panel.Children.Add(CreatePopupSectionLabel("현재 작업 위치", new Thickness(8, 6, 8, 4)));
panel.Children.Add(CreatePopupMenuRow(
"\uED25",
"로컬",
string.IsNullOrWhiteSpace(root) ? "현재 워크스페이스" : root,
!string.IsNullOrWhiteSpace(root) && string.Equals(active, root, StringComparison.OrdinalIgnoreCase),
accentBrush,
secondaryText,
primaryText,
() =>
{
popup.IsOpen = false;
if (!string.IsNullOrWhiteSpace(root))
SwitchToWorkspace(root, root);
}));
if (!string.IsNullOrWhiteSpace(active) && !string.Equals(active, root, StringComparison.OrdinalIgnoreCase))
{
panel.Children.Add(CreatePopupMenuRow(
"\uE7BA",
Path.GetFileName(active),
active,
true,
accentBrush,
secondaryText,
primaryText,
() =>
{
popup.IsOpen = false;
SwitchToWorkspace(active, root);
}));
}
if (variants.Count > 0)
{
panel.Children.Add(CreatePopupSectionLabel($"워크트리 / 복사본 · {variants.Count}", new Thickness(8, 10, 8, 4)));
foreach (var variant in variants)
{
var isActive = !string.IsNullOrWhiteSpace(active) &&
string.Equals(Path.GetFullPath(variant), Path.GetFullPath(active), StringComparison.OrdinalIgnoreCase);
panel.Children.Add(CreatePopupMenuRow(
"\uE8B7",
Path.GetFileName(variant),
isActive ? $"현재 선택 · {variant}" : variant,
isActive,
accentBrush,
secondaryText,
primaryText,
() =>
{
popup.IsOpen = false;
SwitchToWorkspace(variant, root);
}));
}
}
panel.Children.Add(CreatePopupSectionLabel("새 작업 위치", new Thickness(8, 10, 8, 4)));
panel.Children.Add(CreatePopupMenuRow(
"\uE943",
"현재 브랜치로 워크트리 생성",
"Git 저장소면 분리된 작업 복사본을 만들고 전환합니다",
false,
accentBrush,
secondaryText,
primaryText,
() =>
{
popup.IsOpen = false;
_ = CreateCurrentBranchWorktreeAsync();
}));
popup.IsOpen = true;
}
private Border CreatePopupMenuRow(
string icon,
string title,
string description,
bool selected,
Brush accentBrush,
Brush secondaryText,
Brush primaryText,
Action? onClick)
{
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB");
var hintBackground = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
var row = new Border
{
Background = selected ? hintBackground : Brushes.Transparent,
BorderBrush = borderColor,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(10, 8, 10, 8),
Cursor = Cursors.Hand,
Margin = new Thickness(0),
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var iconBlock = new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = selected ? accentBrush : secondaryText,
Margin = new Thickness(0, 1, 8, 0),
VerticalAlignment = VerticalAlignment.Top,
};
grid.Children.Add(iconBlock);
var textStack = new StackPanel();
textStack.Children.Add(new TextBlock
{
Text = title,
FontSize = 12,
FontWeight = selected ? FontWeights.SemiBold : FontWeights.Medium,
Foreground = primaryText,
});
textStack.Children.Add(new TextBlock
{
Text = description,
FontSize = 10.5,
Foreground = secondaryText,
Margin = new Thickness(0, 2, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
Grid.SetColumn(textStack, 1);
grid.Children.Add(textStack);
if (selected)
{
var check = CreateSimpleCheck(accentBrush, 14);
Grid.SetColumn(check, 2);
grid.Children.Add(check);
}
row.Child = grid;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
row.MouseEnter += (_, _) => row.Background = selected ? hintBackground : hoverBg;
row.MouseLeave += (_, _) => row.Background = selected ? hintBackground : Brushes.Transparent;
row.MouseLeftButtonUp += (_, _) => onClick?.Invoke();
return row;
}
private void SwitchToWorkspace(string targetPath, string rootPath)
{
if (string.IsNullOrWhiteSpace(targetPath) || !Directory.Exists(targetPath))
return;
if (!string.IsNullOrWhiteSpace(rootPath))
{
var state = WorktreeStateStore.Load(rootPath);
state.Active = targetPath;
WorktreeStateStore.Save(rootPath, state);
}
SetWorkFolder(targetPath);
ShowToast(string.Equals(targetPath, rootPath, StringComparison.OrdinalIgnoreCase) ? "로컬 워크스페이스로 전환했습니다." : "워크트리로 전환했습니다.");
}
private async Task CreateCurrentBranchWorktreeAsync()
{
var currentFolder = GetCurrentWorkFolder();
if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder))
return;
var root = WorktreeStateStore.ResolveRoot(currentFolder);
var gitRoot = ResolveGitRoot(root);
if (!string.IsNullOrWhiteSpace(gitRoot))
{
await CreateGitWorktreeAsync(gitRoot);
return;
}
var copied = CreateWorkspaceCopy(root);
SwitchToWorkspace(copied, root);
}
private async Task CreateGitWorktreeAsync(string gitRoot)
{
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return;
var branchResult = await RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, CancellationToken.None);
var branchName = branchResult.ExitCode == 0 ? branchResult.StdOut.Trim() : "worktree";
if (string.IsNullOrWhiteSpace(branchName))
branchName = "worktree";
var safeBranch = string.Concat(branchName.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-');
if (string.IsNullOrWhiteSpace(safeBranch))
safeBranch = "worktree";
var parent = Directory.GetParent(gitRoot)?.FullName ?? gitRoot;
var repoName = Path.GetFileName(gitRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var suffix = DateTime.Now.ToString("MMddHHmm");
var worktreePath = Path.Combine(parent, $"{repoName}-wt-{safeBranch}-{suffix}");
var worktreeBranch = $"ax/{safeBranch}-{suffix}";
var addResult = await RunGitAsync(gitPath, gitRoot, new[] { "worktree", "add", "-b", worktreeBranch, worktreePath, branchName }, CancellationToken.None);
if (addResult.ExitCode != 0)
{
CustomMessageBox.Show($"워크트리 생성에 실패했습니다.\n{addResult.StdErr.Trim()}", "워크트리", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
SwitchToWorkspace(worktreePath, gitRoot);
await RefreshGitBranchStatusAsync();
}
private string CreateWorkspaceCopy(string root)
{
var parent = Directory.GetParent(root)?.FullName ?? root;
var repoName = Path.GetFileName(root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var copyPath = Path.Combine(parent, $"{repoName}-copy-{DateTime.Now:MMddHHmm}");
CopyDirectoryRecursive(root, copyPath, skipGitMetadata: true);
return copyPath;
}
private static void CopyDirectoryRecursive(string source, string destination, bool skipGitMetadata)
{
Directory.CreateDirectory(destination);
foreach (var file in Directory.GetFiles(source))
{
var name = Path.GetFileName(file);
if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase))
continue;
File.Copy(file, Path.Combine(destination, name), overwrite: true);
}
foreach (var directory in Directory.GetDirectories(source))
{
var name = Path.GetFileName(directory);
if (skipGitMetadata && string.Equals(name, ".git", StringComparison.OrdinalIgnoreCase))
continue;
CopyDirectoryRecursive(directory, Path.Combine(destination, name), skipGitMetadata);
}
}
/// <summary>하단 바에 실행 이력 상세도 선택 버튼을 추가합니다.</summary>
private void AppendLogLevelButton()
{
// 구분선
MoodIconPanel.Children.Add(new Border
{
Width = 1, Height = 18,
Background = TryFindResource("SeparatorColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(4, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
});
var currentLevel = _settings.Settings.Llm.AgentLogLevel ?? "simple";
var levelLabel = currentLevel switch
{
"debug" => "디버그",
"detailed" => "상세",
_ => "간략",
};
var logBtn = CreateFolderBarButton("\uE946", levelLabel, "실행 이력 상세도", "#059669");
logBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowLogLevelMenu(); };
try { RegisterName("BtnLogLevelMenu", logBtn); } catch { try { UnregisterName("BtnLogLevelMenu"); RegisterName("BtnLogLevelMenu", logBtn); } catch { } }
MoodIconPanel.Children.Add(logBtn);
}
/// <summary>실행 이력 상세도 팝업 메뉴를 표시합니다.</summary>
private void ShowLogLevelMenu()
{
FormatMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var levels = new (string Key, string Label, string Desc)[]
{
("simple", "Simple (간략)", "도구 결과만 한 줄로 표시"),
("detailed", "Detailed (상세)", "도구 호출/결과 + 접이식 상세"),
("debug", "Debug (디버그)", "모든 정보 + 파라미터 표시"),
};
var current = _settings.Settings.Llm.AgentLogLevel ?? "simple";
foreach (var (key, label, desc) in levels)
{
var isActive = current == key;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 13,
Foreground = isActive ? accentBrush : primaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = desc,
FontSize = 10,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var item = new Border
{
Child = sp,
Padding = new Thickness(12, 8, 12, 8),
CornerRadius = new CornerRadius(6),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
};
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
item.MouseEnter += (s, _) => ((Border)s!).Background = hoverBg;
item.MouseLeave += (s, _) => ((Border)s!).Background = Brushes.Transparent;
item.MouseLeftButtonUp += (_, _) =>
{
_settings.Settings.Llm.AgentLogLevel = key;
_settings.Save();
FormatMenuPopup.IsOpen = false;
if (_activeTab == "Cowork") BuildBottomBar();
else if (_activeTab == "Code") BuildCodeBottomBar();
};
FormatMenuItems.Children.Add(item);
}
try
{
var target = FindName("BtnLogLevelMenu") as UIElement;
if (target != null) FormatMenuPopup.PlacementTarget = target;
}
catch { }
FormatMenuPopup.IsOpen = true;
}
private void ShowLanguageMenu()
{
FormatMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var languages = new (string Key, string Label, string Icon)[]
{
("auto", "자동 감지", "🔧"),
("python", "Python", "🐍"),
("java", "Java", "☕"),
("csharp", "C# (.NET)", "🔷"),
("cpp", "C/C++", "⚙"),
("javascript", "JavaScript / Vue", "🌐"),
};
foreach (var (key, label, icon) in languages)
{
var isActive = _selectedLanguage == key;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
sp.Children.Add(new TextBlock { Text = icon, FontSize = 13, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0) });
sp.Children.Add(new TextBlock { Text = label, FontSize = 13, Foreground = isActive ? accentBrush : primaryText, FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal });
var itemBorder = new Border
{
Child = sp, Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8), Cursor = Cursors.Hand,
Padding = new Thickness(8, 7, 12, 7),
};
ApplyMenuItemHover(itemBorder);
var capturedKey = key;
itemBorder.MouseLeftButtonUp += (_, _) =>
{
FormatMenuPopup.IsOpen = false;
_selectedLanguage = capturedKey;
BuildCodeBottomBar();
};
FormatMenuItems.Children.Add(itemBorder);
}
if (FindName("BtnLangMenu") is UIElement langTarget)
FormatMenuPopup.PlacementTarget = langTarget;
FormatMenuPopup.IsOpen = true;
}
/// <summary>폴더바 내 드롭다운 버튼 (소극/적극 스타일과 동일)</summary>
private Border CreateFolderBarButton(string? mdlIcon, string label, string tooltip, string? iconColorHex = null)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E5E7EB");
var hoverBackground = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
var iconColor = iconColorHex != null ? BrushFromHex(iconColorHex) : secondaryText;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
if (mdlIcon != null)
{
sp.Children.Add(new TextBlock
{
Text = mdlIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = iconColor,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
}
sp.Children.Add(new TextBlock
{
Text = label,
FontSize = 12,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var chip = new Border
{
Child = sp,
Background = Brushes.Transparent,
BorderBrush = borderColor,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(10, 5, 10, 5),
Margin = new Thickness(0, 0, 4, 0),
Cursor = Cursors.Hand,
ToolTip = tooltip,
};
chip.MouseEnter += (_, _) => chip.Background = hoverBackground;
chip.MouseLeave += (_, _) => chip.Background = Brushes.Transparent;
return chip;
}
private static string GetFormatLabel(string key) => key switch
{
"xlsx" => "Excel",
"html" => "HTML 보고서",
"docx" => "Word",
"md" => "Markdown",
"csv" => "CSV",
_ => "AI 자동",
};
/// <summary>현재 프리셋/카테고리에 맞는 에이전트 이름, 심볼, 색상을 반환합니다.</summary>
private (string Name, string Symbol, string Color) GetAgentIdentity()
{
string? category = null;
lock (_convLock)
{
category = _currentConversation?.Category;
}
return category switch
{
// Cowork 프리셋 카테고리
"보고서" => ("보고서 에이전트", "◆", "#3B82F6"),
"데이터" => ("데이터 분석 에이전트", "◆", "#10B981"),
"문서" => ("문서 작성 에이전트", "◆", "#6366F1"),
"논문" => ("논문 분석 에이전트", "◆", "#6366F1"),
"파일" => ("파일 관리 에이전트", "◆", "#8B5CF6"),
"자동화" => ("자동화 에이전트", "◆", "#EF4444"),
// Code 프리셋 카테고리
"코드개발" => ("코드 개발 에이전트", "◆", "#3B82F6"),
"리팩터링" => ("리팩터링 에이전트", "◆", "#6366F1"),
"코드리뷰" => ("코드 리뷰 에이전트", "◆", "#10B981"),
"보안점검" => ("보안 점검 에이전트", "◆", "#EF4444"),
"테스트" => ("테스트 에이전트", "◆", "#F59E0B"),
// Chat 카테고리
"연구개발" => ("연구개발 에이전트", "◆", "#0EA5E9"),
"시스템" => ("시스템 에이전트", "◆", "#64748B"),
"수율분석" => ("수율분석 에이전트", "◆", "#F59E0B"),
"제품분석" => ("제품분석 에이전트", "◆", "#EC4899"),
"경영" => ("경영 분석 에이전트", "◆", "#8B5CF6"),
"인사" => ("인사 관리 에이전트", "◆", "#14B8A6"),
"제조기술" => ("제조기술 에이전트", "◆", "#F97316"),
"재무" => ("재무 분석 에이전트", "◆", "#6366F1"),
_ when _activeTab == "Code" => ("코드 에이전트", "◆", "#3B82F6"),
_ when _activeTab == "Cowork" => ("코워크 에이전트", "◆", "#4B5EFC"),
_ => ("AX 에이전트", "◆", "#4B5EFC"),
};
}
/// <summary>포맷 선택 팝업 메뉴를 표시합니다.</summary>
private void ShowFormatMenu()
{
FormatMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var currentFormat = _settings.Settings.Llm.DefaultOutputFormat ?? "auto";
var formats = new (string Key, string Label, string Icon, string Color)[]
{
("auto", "AI 자동 선택", "\uE8BD", "#8B5CF6"),
("xlsx", "Excel", "\uE9F9", "#217346"),
("html", "HTML 보고서", "\uE12B", "#E44D26"),
("docx", "Word", "\uE8A5", "#2B579A"),
("md", "Markdown", "\uE943", "#6B7280"),
("csv", "CSV", "\uE9D9", "#10B981"),
};
foreach (var (key, label, icon, color) in formats)
{
var isActive = key == currentFormat;
var sp = new StackPanel { Orientation = Orientation.Horizontal };
// 커스텀 체크 아이콘
sp.Children.Add(CreateCheckIcon(isActive, accentBrush));
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = BrushFromHex(color),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 13,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var itemBorder = new Border
{
Child = sp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 7, 12, 7),
};
ApplyMenuItemHover(itemBorder);
var capturedKey = key;
itemBorder.MouseLeftButtonUp += (_, _) =>
{
FormatMenuPopup.IsOpen = false;
_settings.Settings.Llm.DefaultOutputFormat = capturedKey;
_settings.Save();
RefreshOverlaySettingsPanel();
BuildBottomBar();
};
FormatMenuItems.Children.Add(itemBorder);
}
// PlacementTarget을 동적 등록된 버튼으로 설정
if (FormatMenuPopup.PlacementTarget == null && FindName("BtnFormatMenu") is UIElement formatTarget)
FormatMenuPopup.PlacementTarget = formatTarget;
FormatMenuPopup.IsOpen = true;
}
private void BtnOverlayDefaultOutputFormat_Click(object sender, RoutedEventArgs e)
{
if (sender is UIElement element)
FormatMenuPopup.PlacementTarget = element;
ShowFormatMenu();
}
/// <summary>디자인 무드 선택 팝업 메뉴를 표시합니다.</summary>
private void ShowMoodMenu()
{
MoodMenuItems.Children.Clear();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
// 2열 갤러리 그리드
var grid = new System.Windows.Controls.Primitives.UniformGrid { Columns = 2 };
foreach (var mood in TemplateService.AllMoods)
{
var isActive = _selectedMood == mood.Key;
var isCustom = _settings.Settings.Llm.CustomMoods.Any(cm => cm.Key == mood.Key);
var colors = TemplateService.GetMoodColors(mood.Key);
// 미니 프리뷰 카드
var previewCard = new Border
{
Width = 160, Height = 80,
CornerRadius = new CornerRadius(6),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Background)),
BorderBrush = isActive ? accentBrush : new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)),
BorderThickness = new Thickness(isActive ? 2 : 1),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(2),
};
var previewContent = new StackPanel();
// 헤딩 라인
previewContent.Children.Add(new Border
{
Width = 60, Height = 6, CornerRadius = new CornerRadius(2),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.PrimaryText)),
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 0, 0, 4),
});
// 악센트 라인
previewContent.Children.Add(new Border
{
Width = 40, Height = 3, CornerRadius = new CornerRadius(1),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Accent)),
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 0, 0, 6),
});
// 텍스트 라인들
for (int i = 0; i < 3; i++)
{
previewContent.Children.Add(new Border
{
Width = 120 - i * 20, Height = 3, CornerRadius = new CornerRadius(1),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.SecondaryText)) { Opacity = 0.5 },
HorizontalAlignment = HorizontalAlignment.Left,
Margin = new Thickness(0, 0, 0, 3),
});
}
// 미니 카드 영역
var cardRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
for (int i = 0; i < 2; i++)
{
cardRow.Children.Add(new Border
{
Width = 28, Height = 14, CornerRadius = new CornerRadius(2),
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.CardBg)),
BorderBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(colors.Border)),
BorderThickness = new Thickness(0.5),
Margin = new Thickness(0, 0, 4, 0),
});
}
previewContent.Children.Add(cardRow);
previewCard.Child = previewContent;
// 무드 라벨
var labelPanel = new StackPanel { Margin = new Thickness(4, 2, 4, 4) };
var labelRow = new StackPanel { Orientation = Orientation.Horizontal };
labelRow.Children.Add(new TextBlock
{
Text = mood.Icon, FontSize = 12,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
});
labelRow.Children.Add(new TextBlock
{
Text = mood.Label, FontSize = 11.5,
Foreground = primaryText,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
VerticalAlignment = VerticalAlignment.Center,
});
if (isActive)
{
labelRow.Children.Add(new TextBlock
{
Text = " ✓", FontSize = 11,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center,
});
}
labelPanel.Children.Add(labelRow);
// 전체 카드 래퍼
var cardWrapper = new Border
{
CornerRadius = new CornerRadius(8),
Background = Brushes.Transparent,
Cursor = Cursors.Hand,
Padding = new Thickness(4),
Margin = new Thickness(2),
};
var wrapperContent = new StackPanel();
wrapperContent.Children.Add(previewCard);
wrapperContent.Children.Add(labelPanel);
cardWrapper.Child = wrapperContent;
// 호버
cardWrapper.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x12, 0xFF, 0xFF, 0xFF)); };
cardWrapper.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
var capturedMood = mood;
cardWrapper.MouseLeftButtonUp += (_, _) =>
{
MoodMenuPopup.IsOpen = false;
_selectedMood = capturedMood.Key;
_settings.Settings.Llm.DefaultMood = capturedMood.Key;
_settings.Save();
SaveConversationSettings();
RefreshOverlaySettingsPanel();
BuildBottomBar();
};
// 커스텀 무드: 우클릭
if (isCustom)
{
cardWrapper.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
MoodMenuPopup.IsOpen = false;
ShowCustomMoodContextMenu(s as Border, capturedMood.Key);
};
}
grid.Children.Add(cardWrapper);
}
MoodMenuItems.Children.Add(grid);
// ── 구분선 + 추가 버튼 ──
MoodMenuItems.Children.Add(new System.Windows.Shapes.Rectangle
{
Height = 1,
Fill = borderBrush,
Margin = new Thickness(8, 4, 8, 4),
Opacity = 0.4,
});
var addSp = new StackPanel { Orientation = Orientation.Horizontal };
addSp.Children.Add(new TextBlock
{
Text = "\uE710",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 8, 0),
});
addSp.Children.Add(new TextBlock
{
Text = "커스텀 무드 추가",
FontSize = 13,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var addBorder = new Border
{
Child = addSp,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(8),
Cursor = Cursors.Hand,
Padding = new Thickness(8, 6, 12, 6),
};
ApplyMenuItemHover(addBorder);
addBorder.MouseLeftButtonUp += (_, _) =>
{
MoodMenuPopup.IsOpen = false;
ShowCustomMoodDialog();
};
MoodMenuItems.Children.Add(addBorder);
if (MoodMenuPopup.PlacementTarget == null && FindName("BtnMoodMenu") is UIElement moodTarget)
MoodMenuPopup.PlacementTarget = moodTarget;
MoodMenuPopup.IsOpen = true;
}
private void BtnOverlayDefaultMood_Click(object sender, RoutedEventArgs e)
{
if (sender is UIElement element)
MoodMenuPopup.PlacementTarget = element;
ShowMoodMenu();
}
/// <summary>커스텀 무드 추가/편집 다이얼로그를 표시합니다.</summary>
private void ShowCustomMoodDialog(Models.CustomMoodEntry? existing = null)
{
bool isEdit = existing != null;
var dlg = new CustomMoodDialog(
existingKey: existing?.Key ?? "",
existingLabel: existing?.Label ?? "",
existingIcon: existing?.Icon ?? "🎯",
existingDesc: existing?.Description ?? "",
existingCss: existing?.Css ?? "")
{
Owner = this,
};
if (dlg.ShowDialog() == true)
{
if (isEdit)
{
existing!.Label = dlg.MoodLabel;
existing.Icon = dlg.MoodIcon;
existing.Description = dlg.MoodDescription;
existing.Css = dlg.MoodCss;
}
else
{
_settings.Settings.Llm.CustomMoods.Add(new Models.CustomMoodEntry
{
Key = dlg.MoodKey,
Label = dlg.MoodLabel,
Icon = dlg.MoodIcon,
Description = dlg.MoodDescription,
Css = dlg.MoodCss,
});
}
_settings.Save();
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildBottomBar();
}
}
/// <summary>커스텀 무드 우클릭 컨텍스트 메뉴.</summary>
private void ShowCustomMoodContextMenu(Border? anchor, string moodKey)
{
if (anchor == null) return;
var popup = new System.Windows.Controls.Primitives.Popup
{
PlacementTarget = anchor,
Placement = System.Windows.Controls.Primitives.PlacementMode.Right,
StaysOpen = false, AllowsTransparency = true,
};
var menuBg = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var menuBorder = new Border
{
Background = menuBg,
CornerRadius = new CornerRadius(10),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(4),
MinWidth = 120,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 12, ShadowDepth = 2, Opacity = 0.3, Color = Colors.Black,
},
};
var stack = new StackPanel();
var editItem = CreateContextMenuItem("\uE70F", "편집", primaryText, secondaryText);
editItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var entry = _settings.Settings.Llm.CustomMoods.FirstOrDefault(c => c.Key == moodKey);
if (entry != null) ShowCustomMoodDialog(entry);
};
stack.Children.Add(editItem);
var deleteItem = CreateContextMenuItem("\uE74D", "삭제", new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)), secondaryText);
deleteItem.MouseLeftButtonDown += (_, _) =>
{
popup.IsOpen = false;
var result = CustomMessageBox.Show(
$"이 디자인 무드를 삭제하시겠습니까?",
"무드 삭제", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
{
_settings.Settings.Llm.CustomMoods.RemoveAll(c => c.Key == moodKey);
if (_selectedMood == moodKey) _selectedMood = "modern";
_settings.Save();
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildBottomBar();
}
};
stack.Children.Add(deleteItem);
menuBorder.Child = stack;
popup.Child = menuBorder;
popup.IsOpen = true;
}
private string? _promptCardPlaceholder;
private void ShowPlaceholder()
{
if (string.IsNullOrEmpty(_promptCardPlaceholder)) return;
InputWatermark.Text = _promptCardPlaceholder;
InputWatermark.Visibility = Visibility.Visible;
InputBox.Text = "";
InputBox.Focus();
}
private void UpdateWatermarkVisibility()
{
// 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지)
if (_slashPalette.ActiveCommand != null)
{
InputWatermark.Visibility = Visibility.Collapsed;
return;
}
if (_promptCardPlaceholder != null && string.IsNullOrEmpty(InputBox.Text))
InputWatermark.Visibility = Visibility.Visible;
else
InputWatermark.Visibility = Visibility.Collapsed;
}
private void ClearPromptCardPlaceholder()
{
_promptCardPlaceholder = null;
InputWatermark.Visibility = Visibility.Collapsed;
}
private void BtnSettings_Click(object sender, RoutedEventArgs e)
{
OpenAgentSettingsWindow();
}
// ─── 프롬프트 템플릿 팝업 ────────────────────────────────────────────
private void BtnTemplateSelector_Click(object sender, RoutedEventArgs e)
{
var templates = _settings.Settings.Llm.PromptTemplates;
TemplateItems.Items.Clear();
var search = TemplateSearchBox?.Text?.Trim() ?? "";
if (templates == null || templates.Count == 0)
{
TemplateEmptyHint.Visibility = Visibility.Visible;
TemplatePopup.IsOpen = true;
return;
}
if (!string.IsNullOrWhiteSpace(search))
{
templates = templates
.Where(t => t.Name.Contains(search, StringComparison.OrdinalIgnoreCase)
|| t.Content.Contains(search, StringComparison.OrdinalIgnoreCase))
.ToList();
}
TemplateEmptyHint.Visibility = templates.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
if (templates.Count == 0)
{
TemplateEmptyHint.Text = "검색 결과가 없습니다.";
TemplatePopup.IsOpen = true;
return;
}
TemplateEmptyHint.Text = "등록된 템플릿이 없습니다. 설정에서 추가하세요.";
var favoriteSet = new HashSet<string>(_settings.Settings.Llm.FavoritePromptTemplates ?? [], StringComparer.OrdinalIgnoreCase);
var recentList = _settings.Settings.Llm.RecentPromptTemplates ?? [];
var recentRank = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < recentList.Count; i++)
{
var key = recentList[i]?.Trim();
if (!string.IsNullOrWhiteSpace(key) && !recentRank.ContainsKey(key))
recentRank[key] = i;
}
void AddSectionHeader(string title)
{
TemplateItems.Items.Add(new Border
{
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(10, 8, 10, 6),
Child = new TextBlock
{
Text = title,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
}
});
}
void AddTemplateRow(PromptTemplate tpl, bool isFavorite, bool isRecent)
{
var item = new Border
{
Background = Brushes.Transparent,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
BorderThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
Padding = new Thickness(10, 10, 10, 10),
Margin = new Thickness(0, 0, 0, 0),
Cursor = System.Windows.Input.Cursors.Hand,
Tag = tpl.Content,
};
var stack = new StackPanel();
var titleRow = new StackPanel { Orientation = Orientation.Horizontal };
titleRow.Children.Add(new TextBlock
{
Text = tpl.Name,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = (Brush)FindResource("PrimaryText"),
});
if (isFavorite)
{
titleRow.Children.Add(new TextBlock
{
Text = " 고정",
FontSize = 10,
Foreground = BrushFromHex("#B45309"),
});
}
else if (isRecent)
{
titleRow.Children.Add(new TextBlock
{
Text = " 최근",
FontSize = 10,
Foreground = BrushFromHex("#2563EB"),
});
}
stack.Children.Add(titleRow);
var preview = tpl.Content.Length > 60 ? tpl.Content[..60] + "…" : tpl.Content;
stack.Children.Add(new TextBlock
{
Text = preview,
FontSize = 10.5,
Foreground = (Brush)FindResource("SecondaryText"),
TextTrimming = TextTrimming.CharacterEllipsis,
Margin = new Thickness(0, 2, 0, 0),
});
item.Child = stack;
item.MouseEnter += (s, _) =>
{
if (s is Border b) b.Background = (Brush)FindResource("ItemHoverBackground");
};
item.MouseLeave += (s, _) =>
{
if (s is Border b) b.Background = Brushes.Transparent;
};
item.MouseLeftButtonUp += (s, _) =>
{
if (s is Border b && b.Tag is string content)
{
RegisterRecentPromptTemplate(tpl.Name);
InputBox.Text = content;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
TemplatePopup.IsOpen = false;
}
};
item.MouseRightButtonUp += (_, e) =>
{
e.Handled = true;
ToggleFavoritePromptTemplate(tpl.Name);
BtnTemplateSelector_Click(this, new RoutedEventArgs());
};
TemplateItems.Items.Add(item);
}
var favorites = templates.Where(t => favoriteSet.Contains(t.Name)).ToList();
var recents = templates.Where(t => !favoriteSet.Contains(t.Name) && recentRank.ContainsKey(t.Name))
.OrderBy(t => recentRank[t.Name])
.ToList();
var remaining = templates.Where(t => !favoriteSet.Contains(t.Name) && !recentRank.ContainsKey(t.Name))
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (favorites.Count > 0)
{
AddSectionHeader("고정");
foreach (var tpl in favorites)
AddTemplateRow(tpl, true, false);
}
if (recents.Count > 0)
{
AddSectionHeader("최근");
foreach (var tpl in recents)
AddTemplateRow(tpl, false, true);
}
if (remaining.Count > 0)
{
AddSectionHeader("전체");
foreach (var tpl in remaining)
AddTemplateRow(tpl, false, false);
}
TemplatePopup.IsOpen = true;
}
private void TemplateSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (TemplatePopup?.IsOpen == true)
BtnTemplateSelector_Click(this, new RoutedEventArgs());
}
private void RegisterRecentPromptTemplate(string name)
{
if (string.IsNullOrWhiteSpace(name))
return;
var recent = _settings.Settings.Llm.RecentPromptTemplates ??= new List<string>();
recent.RemoveAll(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
recent.Insert(0, name);
if (recent.Count > 10)
recent.RemoveRange(10, recent.Count - 10);
_settings.Save();
}
private void ToggleFavoritePromptTemplate(string name)
{
if (string.IsNullOrWhiteSpace(name))
return;
var favorites = _settings.Settings.Llm.FavoritePromptTemplates ??= new List<string>();
var existing = favorites.FirstOrDefault(x => string.Equals(x, name, StringComparison.OrdinalIgnoreCase));
if (existing != null)
favorites.Remove(existing);
else
favorites.Add(name);
_settings.Save();
}
// ─── 모델 전환 ──────────────────────────────────────────────────────
// Gemini/Claude 사전 정의 모델 목록
private static readonly (string Id, string Label)[] GeminiModels =
{
("gemini-2.5-pro", "Gemini 2.5 Pro"),
("gemini-2.5-flash", "Gemini 2.5 Flash"),
("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
("gemini-2.0-flash", "Gemini 2.0 Flash"),
("gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite"),
};
private static readonly (string Id, string Label)[] ClaudeModels =
{
(string.Concat("cl", "aude-opus-4-6"), "Claude Opus 4.6"),
(string.Concat("cl", "aude-sonnet-4-6"), "Claude Sonnet 4.6"),
(string.Concat("cl", "aude-haiku-4-5-20251001"), "Claude Haiku 4.5"),
(string.Concat("cl", "aude-sonnet-4-5-20250929"), "Claude Sonnet 4.5"),
(string.Concat("cl", "aude-opus-4-20250514"), "Claude Opus 4"),
};
/// <summary>현재 선택된 모델의 표시명을 반환합니다.</summary>
private string GetCurrentModelDisplayName()
{
var llm = _settings.Settings.Llm;
var service = llm.Service.ToLowerInvariant();
if (service is "ollama" or "vllm")
{
// 등록 모델에서 별칭 찾기
var registered = llm.RegisteredModels
.FirstOrDefault(rm => rm.EncryptedModelName == llm.Model);
if (registered != null) return registered.Alias;
return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : "••••";
}
if (service == "gemini")
{
var m = GeminiModels.FirstOrDefault(g => g.Id == llm.Model);
return m.Label ?? llm.Model;
}
if (service is "sigmoid" or "cl" + "aude")
{
var m = ClaudeModels.FirstOrDefault(c => c.Id == llm.Model);
return m.Label ?? llm.Model;
}
return string.IsNullOrEmpty(llm.Model) ? "(미설정)" : llm.Model;
}
private void UpdateModelLabel()
{
var service = _settings.Settings.Llm.Service.ToLowerInvariant();
var serviceLabel = service switch
{
"gemini" => "Gemini",
"sigmoid" or "cl" + "aude" => "Claude",
"vllm" => "vLLM",
_ => "Ollama",
};
var model = GetCurrentModelDisplayName();
const int maxLen = 26;
if (model.Length > maxLen)
model = model[..(maxLen - 1)] + "…";
ModelLabel.Text = $"{serviceLabel} · {model}";
}
private static string NextPlanMode(string current) => (current ?? "off").ToLowerInvariant() switch
{
"off" => "auto",
"auto" => "always",
_ => "off",
};
private static string PlanModeLabel(string value) => (value ?? "off").ToLowerInvariant() switch
{
"always" => "항상 계획",
"auto" => "자동 계획",
_ => "끄기",
};
private static string NextReasoning(string current) => (current ?? "normal").ToLowerInvariant() switch
{
"minimal" => "normal",
"normal" => "detailed",
_ => "minimal",
};
private static string ReasoningLabel(string value) => (value ?? "normal").ToLowerInvariant() switch
{
"minimal" => "낮음",
"detailed" => "높음",
_ => "중간",
};
private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch
{
"deny" => "Default",
"default" => "AcceptEdits",
"acceptedits" => "Plan",
"plan" => "BypassPermissions",
"bypasspermissions" => "Default",
_ => "Default",
};
private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch
{
"gemini" => "Gemini",
"sigmoid" or "cl" + "aude" => "Claude",
"vllm" => "vLLM",
_ => "Ollama",
};
private List<(string Id, string Label)> GetModelCandidates(string service)
{
var llm = _settings.Settings.Llm;
var normalized = (service ?? "ollama").ToLowerInvariant();
if (normalized is "ollama" or "vllm")
{
return llm.RegisteredModels
.Where(rm => string.Equals(rm.Service, normalized, StringComparison.OrdinalIgnoreCase))
.Select(rm => (rm.EncryptedModelName, rm.Alias))
.ToList();
}
if (normalized == "gemini")
return GeminiModels.Select(m => (m.Id, m.Label)).ToList();
if (normalized is "sigmoid" or "cl" + "aude")
return ClaudeModels.Select(m => (m.Id, m.Label)).ToList();
return [];
}
private void RefreshInlineSettingsPanel()
{
if (InlineSettingsPanel == null) return;
var llm = _settings.Settings.Llm;
var service = (llm.Service ?? "ollama").ToLowerInvariant();
var models = GetModelCandidates(service);
_isInlineSettingsSyncing = true;
try
{
if (InlineServiceCardPanel != null)
InlineServiceCardPanel.Children.Clear();
if (InlineModelListPanel != null)
InlineModelListPanel.Children.Clear();
CmbInlineService.Items.Clear();
foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" })
{
CmbInlineService.Items.Add(new ComboBoxItem
{
Content = ServiceLabel(svc),
Tag = svc,
});
}
var normalizedService = service == "sigmoid" ? string.Concat("cl", "aude") : service;
CmbInlineService.SelectedIndex = Math.Max(0, new[] { "ollama", "vllm", "gemini", "claude" }.ToList().IndexOf(normalizedService));
BuildInlineServiceCards(normalizedService);
CmbInlineModel.Items.Clear();
InlineModelChipPanel.Children.Clear();
if (models.Count == 0)
{
CmbInlineModel.Items.Add(new ComboBoxItem { Content = "등록된 모델 없음", IsEnabled = false });
CmbInlineModel.SelectedIndex = 0;
if (InlineModelChipPanel != null)
InlineModelChipPanel.Visibility = Visibility.Collapsed;
}
else
{
foreach (var (id, label) in models)
{
CmbInlineModel.Items.Add(new ComboBoxItem
{
Content = label,
Tag = id,
});
}
var selectedIndex = models.FindIndex(m => m.Id == llm.Model);
CmbInlineModel.SelectedIndex = selectedIndex >= 0 ? selectedIndex : 0;
BuildInlineModelRows(models, llm.Model);
if (InlineModelChipPanel != null)
{
InlineModelChipPanel.Visibility = Visibility.Visible;
foreach (var (id, label) in models.Take(6))
{
var isActive = string.Equals(id, llm.Model, StringComparison.OrdinalIgnoreCase);
var chip = new Border
{
Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent,
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : (TryFindResource("BorderColor") as Brush ?? Brushes.Gray),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(10, 5, 10, 5),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
};
var text = new TextBlock
{
Text = label,
FontSize = 10.5,
Foreground = isActive
? BrushFromHex("#1D4ED8")
: (TryFindResource("PrimaryText") as Brush ?? Brushes.White),
};
chip.Child = text;
var capturedId = id;
var capturedLabel = label;
chip.MouseEnter += (_, _) =>
{
if (!isActive)
chip.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
};
chip.MouseLeave += (_, _) =>
{
if (!isActive)
chip.Background = Brushes.Transparent;
};
chip.MouseLeftButtonUp += (_, _) =>
{
llm.Model = capturedId;
_settings.Save();
UpdateModelLabel();
RefreshInlineSettingsPanel();
SetStatus($"모델 전환: {capturedLabel}", spinning: false);
};
InlineModelChipPanel.Children.Add(chip);
}
}
}
BtnInlineFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐");
BtnInlineReasoning.Content = GetQuickActionLabel("추론", ReasoningLabel(llm.AgentDecisionLevel));
BtnInlinePlanMode.Content = GetQuickActionLabel("계획", PlanModeLabel(llm.PlanMode));
BtnInlinePermission.Content = GetQuickActionLabel("권한", PermissionModeCatalog.ToDisplayLabel(llm.FilePermission));
BtnInlineSkill.Content = $"스킬 · {(llm.EnableSkillSystem ? "On" : "Off")}";
BtnInlineCommandBrowser.Content = "명령/스킬 브라우저";
var mcpTotal = llm.McpServers?.Count ?? 0;
var mcpEnabled = llm.McpServers?.Count(x => x.Enabled) ?? 0;
BtnInlineMcp.Content = $"MCP 상태 · {mcpEnabled}/{mcpTotal}";
ApplyQuickActionVisual(BtnInlineFastMode, llm.FreeTierMode, "#ECFDF5", "#166534");
ApplyQuickActionVisual(BtnInlineReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8");
ApplyQuickActionVisual(BtnInlinePlanMode, !string.Equals(llm.PlanMode, "off", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#4338CA");
ApplyQuickActionVisual(BtnInlinePermission,
!string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase),
"#FFF7ED",
"#C2410C");
}
finally
{
_isInlineSettingsSyncing = false;
}
}
private void BuildInlineServiceCards(string selectedService)
{
if (InlineServiceCardPanel == null)
return;
foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" })
{
var isActive = string.Equals(svc, selectedService, StringComparison.OrdinalIgnoreCase);
var card = new Border
{
Background = isActive ? BrushFromHex("#EEF2FF") : Brushes.Transparent,
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : (TryFindResource("BorderColor") as Brush ?? Brushes.Gray),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 0, 6, 6),
Cursor = Cursors.Hand,
};
var text = new TextBlock
{
Text = ServiceLabel(svc),
FontSize = 10.5,
Foreground = isActive ? BrushFromHex("#1D4ED8") : (TryFindResource("PrimaryText") as Brush ?? Brushes.White),
};
card.Child = text;
var capturedService = svc;
card.MouseEnter += (_, _) =>
{
if (!isActive)
card.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
};
card.MouseLeave += (_, _) =>
{
if (!isActive)
card.Background = Brushes.Transparent;
};
card.MouseLeftButtonUp += (_, _) =>
{
if (_isInlineSettingsSyncing)
return;
_settings.Settings.Llm.Service = capturedService;
_settings.Save();
UpdateModelLabel();
RefreshInlineSettingsPanel();
};
InlineServiceCardPanel.Children.Add(card);
}
}
private void BuildInlineModelRows(List<(string Id, string Label)> models, string? selectedModel)
{
if (InlineModelListPanel == null)
return;
foreach (var (id, label) in models.Take(8))
{
var isActive = string.Equals(id, selectedModel, StringComparison.OrdinalIgnoreCase);
var row = new Border
{
Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent,
BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(isActive ? 2 : 0, 0, 0, 1),
Padding = new Thickness(8, 8, 8, 8),
Cursor = Cursors.Hand,
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var labelText = new TextBlock
{
Text = label,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
};
grid.Children.Add(labelText);
var stateText = new TextBlock
{
Text = isActive ? "사용 중" : "선택",
FontSize = 10,
Foreground = isActive ? BrushFromHex("#2563EB") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(stateText, 1);
grid.Children.Add(stateText);
row.Child = grid;
var capturedId = id;
var capturedLabel = label;
row.MouseEnter += (_, _) =>
{
row.Background = BrushFromHex("#F8FAFC");
row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E2E8F0");
};
row.MouseLeave += (_, _) =>
{
row.Background = isActive ? BrushFromHex("#F8FAFC") : Brushes.Transparent;
row.BorderBrush = isActive ? BrushFromHex("#C7D2FE") : BrushFromHex("#E5E7EB");
};
row.MouseLeftButtonUp += (_, _) =>
{
if (_isInlineSettingsSyncing)
return;
_settings.Settings.Llm.Model = capturedId;
_settings.Save();
UpdateModelLabel();
RefreshInlineSettingsPanel();
SetStatus($"모델 전환: {capturedLabel}", spinning: false);
};
InlineModelListPanel.Children.Add(row);
}
}
private void BtnModelSelector_Click(object sender, RoutedEventArgs e)
{
RefreshInlineSettingsPanel();
InlineSettingsPanel.IsOpen = !InlineSettingsPanel.IsOpen;
if (InlineSettingsPanel.IsOpen)
{
Dispatcher.BeginInvoke(() =>
{
if (CmbInlineModel.Items.Count > 0)
CmbInlineModel.Focus();
else
InputBox.Focus();
}, DispatcherPriority.Input);
}
}
private void OpenAgentSettingsWindow()
{
RefreshOverlaySettingsPanel();
AgentSettingsOverlay.Visibility = Visibility.Visible;
InlineSettingsPanel.IsOpen = false;
SetOverlaySection("common");
Dispatcher.BeginInvoke(() =>
{
if (OverlayNavCommon != null)
OverlayNavCommon.Focus();
else
InputBox.Focus();
}, DispatcherPriority.Input);
}
public void OpenAgentSettingsFromExternal()
{
Dispatcher.BeginInvoke(() =>
{
Show();
Activate();
OpenAgentSettingsWindow();
}, DispatcherPriority.Input);
}
public void RefreshFromSavedSettings()
{
Dispatcher.BeginInvoke(() =>
{
ApplyAgentThemeResources();
LoadConversationSettings();
UpdatePermissionUI();
UpdateDataUsageUI();
UpdateModelLabel();
RefreshInlineSettingsPanel();
RefreshOverlaySettingsPanel();
RefreshContextUsageVisual();
UpdateTabUI();
BuildBottomBar();
RefreshDraftQueueUi();
RefreshConversationList();
}, DispatcherPriority.Input);
}
private void BtnOverlaySettingsClose_Click(object sender, RoutedEventArgs e)
{
ApplyOverlaySettingsChanges(showToast: false, closeOverlay: true);
}
private void ApplyOverlaySettingsChanges(bool showToast, bool closeOverlay)
{
var llm = _settings.Settings.Llm;
_settings.Settings.AiEnabled = ChkOverlayAiEnabled?.IsChecked == true;
llm.EnableProactiveContextCompact = ChkOverlayEnableProactiveCompact?.IsChecked == true;
llm.EnableSkillSystem = ChkOverlayEnableSkillSystem?.IsChecked == true;
llm.EnableToolHooks = ChkOverlayEnableToolHooks?.IsChecked == true;
llm.EnableHookInputMutation = ChkOverlayEnableHookInputMutation?.IsChecked == true;
llm.EnableHookPermissionUpdate = ChkOverlayEnableHookPermissionUpdate?.IsChecked == true;
llm.EnableCoworkVerification = ChkOverlayEnableCoworkVerification?.IsChecked == true;
llm.Code.EnableCodeVerification = ChkOverlayEnableCodeVerification?.IsChecked == true;
llm.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true;
llm.FolderDataUsage = _folderDataUsage;
CommitOverlayEndpointInput(normalizeOnInvalid: true);
CommitOverlayApiKeyInput();
CommitOverlayModelInput(normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, llm.ContextCompactTriggerPercent, 10, 95, value => llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxContextTokens, llm.MaxContextTokens, 1024, 1_000_000, value => llm.MaxContextTokens = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
if (closeOverlay)
AgentSettingsOverlay.Visibility = Visibility.Collapsed;
if (showToast)
ShowToast("AX Agent 설정이 저장되었습니다.");
InputBox.Focus();
}
private void PersistOverlaySettingsState(bool refreshOverlayDeferredInputs)
{
_settings.Save();
_appState.LoadFromSettings(_settings);
ApplyAgentThemeResources();
UpdatePermissionUI();
UpdateDataUsageUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
UpdateModelLabel();
UpdateTabUI();
RefreshOverlayVisualState(refreshOverlayDeferredInputs);
}
private void RefreshOverlayVisualState(bool loadDeferredInputs)
{
var llm = _settings.Settings.Llm;
var service = NormalizeOverlayService(llm.Service);
var models = GetModelCandidates(service);
_isOverlaySettingsSyncing = true;
try
{
if (CmbOverlayService != null)
{
CmbOverlayService.Items.Clear();
foreach (var svc in new[] { "ollama", "vllm", "gemini", "claude" })
{
CmbOverlayService.Items.Add(new ComboBoxItem
{
Content = ServiceLabel(svc),
Tag = svc
});
}
CmbOverlayService.SelectedItem = CmbOverlayService.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(i => string.Equals(i.Tag as string, service, StringComparison.OrdinalIgnoreCase));
}
if (CmbOverlayModel != null)
{
CmbOverlayModel.Items.Clear();
foreach (var model in models)
{
CmbOverlayModel.Items.Add(new ComboBoxItem
{
Content = model.Label,
Tag = model.Id
});
}
CmbOverlayModel.SelectedItem = CmbOverlayModel.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(i => string.Equals(i.Tag as string, llm.Model, StringComparison.OrdinalIgnoreCase));
if (CmbOverlayModel.SelectedItem == null && CmbOverlayModel.Items.Count > 0)
CmbOverlayModel.SelectedIndex = 0;
}
if (OverlaySelectedServiceText != null)
OverlaySelectedServiceText.Text = ServiceLabel(service);
if (OverlaySelectedModelText != null)
OverlaySelectedModelText.Text = string.IsNullOrWhiteSpace(llm.Model)
? "미선택"
: (models.FirstOrDefault(m => string.Equals(m.Id, llm.Model, StringComparison.OrdinalIgnoreCase)).Label ?? llm.Model);
if (loadDeferredInputs)
{
if (ChkOverlayAiEnabled != null)
ChkOverlayAiEnabled.IsChecked = _settings.Settings.AiEnabled;
if (TxtOverlayServiceEndpoint != null)
TxtOverlayServiceEndpoint.Text = GetOverlayServiceEndpoint(service);
if (TxtOverlayServiceApiKey != null)
TxtOverlayServiceApiKey.Password = GetOverlayServiceApiKey(service);
if (TxtOverlayContextCompactTriggerPercent != null)
TxtOverlayContextCompactTriggerPercent.Text = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95).ToString();
if (TxtOverlayMaxContextTokens != null)
TxtOverlayMaxContextTokens.Text = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000).ToString();
if (TxtOverlayMaxRetryOnError != null)
TxtOverlayMaxRetryOnError.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString();
if (ChkOverlayEnableProactiveCompact != null)
ChkOverlayEnableProactiveCompact.IsChecked = llm.EnableProactiveContextCompact;
if (ChkOverlayEnableSkillSystem != null)
ChkOverlayEnableSkillSystem.IsChecked = llm.EnableSkillSystem;
if (ChkOverlayEnableToolHooks != null)
ChkOverlayEnableToolHooks.IsChecked = llm.EnableToolHooks;
if (ChkOverlayEnableHookInputMutation != null)
ChkOverlayEnableHookInputMutation.IsChecked = llm.EnableHookInputMutation;
if (ChkOverlayEnableHookPermissionUpdate != null)
ChkOverlayEnableHookPermissionUpdate.IsChecked = llm.EnableHookPermissionUpdate;
if (ChkOverlayEnableCoworkVerification != null)
ChkOverlayEnableCoworkVerification.IsChecked = llm.EnableCoworkVerification;
if (ChkOverlayEnableCodeVerification != null)
ChkOverlayEnableCodeVerification.IsChecked = llm.Code.EnableCodeVerification;
if (ChkOverlayEnableParallelTools != null)
ChkOverlayEnableParallelTools.IsChecked = llm.EnableParallelTools;
}
if (BtnOverlayDefaultOutputFormat != null)
BtnOverlayDefaultOutputFormat.Content = $"AI 자동 · {GetFormatLabel(llm.DefaultOutputFormat ?? "auto")}";
if (BtnOverlayDefaultMood != null)
{
var mood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == (_selectedMood ?? llm.DefaultMood ?? "modern"));
BtnOverlayDefaultMood.Content = mood == null ? "모던" : $"{mood.Icon} {mood.Label}";
}
RefreshOverlayThemeCards();
RefreshOverlayServiceCards();
RefreshOverlayModeButtons();
RefreshOverlayServiceFieldLabels(service);
BuildOverlayModelChips(service);
RefreshOverlayAdvancedChoiceButtons();
}
finally
{
_isOverlaySettingsSyncing = false;
}
}
private static string NormalizeOverlayService(string? service)
=> string.Equals(service, "sigmoid", StringComparison.OrdinalIgnoreCase) ? "claude" : (service ?? "ollama").Trim().ToLowerInvariant();
private void CommitOverlayEndpointInput(bool normalizeOnInvalid)
{
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
var endpoint = TxtOverlayServiceEndpoint?.Text.Trim() ?? "";
ClearOverlayValidation(TxtOverlayServiceEndpoint);
switch (service)
{
case "ollama":
_settings.Settings.Llm.OllamaEndpoint = endpoint;
_settings.Settings.Llm.Endpoint = endpoint;
break;
case "vllm":
_settings.Settings.Llm.VllmEndpoint = endpoint;
_settings.Settings.Llm.Endpoint = endpoint;
break;
default:
_settings.Settings.Llm.Endpoint = endpoint;
break;
}
if (normalizeOnInvalid && TxtOverlayServiceEndpoint != null)
TxtOverlayServiceEndpoint.Text = endpoint;
}
private void CommitOverlayApiKeyInput()
{
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
var apiKey = TxtOverlayServiceApiKey?.Password ?? "";
switch (service)
{
case "ollama":
_settings.Settings.Llm.OllamaApiKey = apiKey;
_settings.Settings.Llm.ApiKey = apiKey;
break;
case "vllm":
_settings.Settings.Llm.VllmApiKey = apiKey;
_settings.Settings.Llm.ApiKey = apiKey;
break;
case "gemini":
_settings.Settings.Llm.GeminiApiKey = apiKey;
_settings.Settings.Llm.ApiKey = apiKey;
break;
default:
_settings.Settings.Llm.ClaudeApiKey = apiKey;
_settings.Settings.Llm.ApiKey = apiKey;
break;
}
}
private void CommitOverlayModelInput(bool normalizeOnInvalid)
{
if (TxtOverlayModelInput == null || TxtOverlayModelInput.Visibility != Visibility.Visible)
return;
var value = TxtOverlayModelInput?.Text.Trim() ?? "";
if (string.IsNullOrWhiteSpace(value))
{
MarkOverlayValidation(TxtOverlayModelInput, "모델명을 입력하세요.");
if (normalizeOnInvalid && TxtOverlayModelInput != null)
{
TxtOverlayModelInput.Text = _settings.Settings.Llm.Model ?? "";
ClearOverlayValidation(TxtOverlayModelInput);
}
return;
}
ClearOverlayValidation(TxtOverlayModelInput);
_settings.Settings.Llm.Model = value;
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
switch (service)
{
case "ollama":
_settings.Settings.Llm.OllamaModel = value;
break;
case "vllm":
_settings.Settings.Llm.VllmModel = value;
break;
case "gemini":
_settings.Settings.Llm.GeminiModel = value;
break;
default:
_settings.Settings.Llm.ClaudeModel = value;
break;
}
}
private bool CommitOverlayNumericInput(TextBox? textBox, int currentValue, int min, int max, Action<int> applyValue, bool normalizeOnInvalid)
{
if (textBox == null)
return false;
if (!int.TryParse(textBox.Text?.Trim(), out var parsed))
{
MarkOverlayValidation(textBox, $"{min}~{max} 사이 숫자를 입력하세요.");
if (normalizeOnInvalid)
{
textBox.Text = Math.Clamp(currentValue, min, max).ToString();
ClearOverlayValidation(textBox);
}
return false;
}
parsed = Math.Clamp(parsed, min, max);
applyValue(parsed);
textBox.Text = parsed.ToString();
ClearOverlayValidation(textBox);
return true;
}
private void MarkOverlayValidation(Control? control, string message)
{
if (control == null)
return;
control.BorderBrush = BrushFromHex("#DC2626");
control.ToolTip = message;
}
private void ClearOverlayValidation(Control? control)
{
if (control == null)
return;
control.BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
control.ClearValue(ToolTipProperty);
}
private void CommitOverlayModelSelection(string modelId)
{
_settings.Settings.Llm.Model = modelId;
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
switch (service)
{
case "ollama":
_settings.Settings.Llm.OllamaModel = modelId;
break;
case "vllm":
_settings.Settings.Llm.VllmModel = modelId;
break;
case "gemini":
_settings.Settings.Llm.GeminiModel = modelId;
break;
default:
_settings.Settings.Llm.ClaudeModel = modelId;
break;
}
if (TxtOverlayModelInput != null && TxtOverlayModelInput.Visibility == Visibility.Visible)
{
TxtOverlayModelInput.Text = modelId;
ClearOverlayValidation(TxtOverlayModelInput);
}
}
private void ChkOverlayAiEnabled_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
_settings.Settings.AiEnabled = ChkOverlayAiEnabled?.IsChecked == true;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayVllmAllowInsecureTls_Changed(object sender, RoutedEventArgs e)
{
// vLLM SSL 우회는 모델 등록 단계에서만 관리합니다.
}
private void TxtOverlayServiceEndpoint_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
CommitOverlayEndpointInput(normalizeOnInvalid: false);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayServiceApiKey_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
CommitOverlayApiKeyInput();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayModelInput_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
CommitOverlayModelInput(normalizeOnInvalid: false);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void RefreshOverlayAdvancedChoiceButtons()
{
// ToggleSwitch 기반으로 바뀌면서 별도 버튼 시각 동기화는 사용하지 않습니다.
}
private void TxtOverlayContextCompactTriggerPercent_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayContextCompactTriggerPercent, _settings.Settings.Llm.ContextCompactTriggerPercent, 10, 95, value => _settings.Settings.Llm.ContextCompactTriggerPercent = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayMaxContextTokens_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxContextTokens, _settings.Settings.Llm.MaxContextTokens, 1024, 1_000_000, value => _settings.Settings.Llm.MaxContextTokens = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayMaxRetryOnError_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, _settings.Settings.Llm.MaxRetryOnError, 0, 10, value => _settings.Settings.Llm.MaxRetryOnError = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayNav_Checked(object sender, RoutedEventArgs e)
{
if (sender is not RadioButton rb || rb.Tag is not string tag)
return;
SetOverlaySection(tag);
}
private void SetOverlaySection(string tag)
{
if (OverlaySectionService == null || OverlaySectionQuick == null || OverlaySectionDetail == null)
return;
var section = string.IsNullOrWhiteSpace(tag) ? "common" : tag.Trim().ToLowerInvariant();
var showCommon = section == "common";
var showService = section == "service";
var showPermission = section == "permission";
var showAdvanced = section == "advanced";
OverlaySectionService.Visibility = showCommon || showService ? Visibility.Visible : Visibility.Collapsed;
OverlaySectionQuick.Visibility = showCommon || showPermission ? Visibility.Visible : Visibility.Collapsed;
OverlaySectionDetail.Visibility = Visibility.Visible;
if (OverlayAnchorCommon != null)
OverlayAnchorCommon.Text = section switch
{
"service" => "서비스 설정",
"permission" => "권한 설정",
"advanced" => "고급 설정",
_ => "일반 설정"
};
if (OverlayAiEnabledRow != null)
OverlayAiEnabledRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
if (OverlayThemePanel != null)
OverlayThemePanel.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
if (OverlayThemeStylePanel != null)
OverlayThemeStylePanel.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
if (OverlayDefaultOutputFormatRow != null)
OverlayDefaultOutputFormatRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
if (OverlayDefaultMoodRow != null)
OverlayDefaultMoodRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed;
if (OverlayModelEditorPanel != null)
OverlayModelEditorPanel.Visibility = showCommon || showService ? Visibility.Visible : Visibility.Collapsed;
if (OverlayAnchorPermission != null)
OverlayAnchorPermission.Visibility = showPermission ? Visibility.Visible : Visibility.Collapsed;
if (OverlayFolderDataUsageRow != null)
OverlayFolderDataUsageRow.Visibility = showCommon || showPermission ? Visibility.Visible : Visibility.Collapsed;
if (OverlayTlsRow != null)
OverlayTlsRow.Visibility = showCommon || showService || showPermission ? Visibility.Visible : Visibility.Collapsed;
if (OverlayAnchorAdvanced != null)
OverlayAnchorAdvanced.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
if (OverlayMaxContextTokensRow != null)
OverlayMaxContextTokensRow.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
if (OverlayMaxRetryRow != null)
OverlayMaxRetryRow.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
if (OverlayAdvancedTogglePanel != null)
OverlayAdvancedTogglePanel.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed;
}
private void RefreshOverlaySettingsPanel()
{
RefreshOverlayVisualState(loadDeferredInputs: true);
}
private void CmbOverlayService_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service)
return;
var llm = _settings.Settings.Llm;
llm.Service = service;
var candidates = GetModelCandidates(service);
var preferredModel = service switch
{
"ollama" => llm.OllamaModel,
"vllm" => llm.VllmModel,
"gemini" => llm.GeminiModel,
_ => llm.ClaudeModel
};
if (!string.IsNullOrWhiteSpace(preferredModel))
llm.Model = preferredModel;
else if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model))
llm.Model = candidates[0].Id;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
}
private void CmbOverlayModel_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId)
return;
CommitOverlayModelSelection(modelId);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void RefreshOverlayThemeCards()
{
var selected = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant();
SetOverlayCardSelection(OverlayThemeSystemCard, selected == "system");
SetOverlayCardSelection(OverlayThemeLightCard, selected == "light");
SetOverlayCardSelection(OverlayThemeDarkCard, selected == "dark");
var preset = (_settings.Settings.Llm.AgentThemePreset ?? "claw").ToLowerInvariant();
SetOverlayCardSelection(OverlayThemeStyleClawCard, preset == "claw");
SetOverlayCardSelection(OverlayThemeStyleCodexCard, preset == "codex");
SetOverlayCardSelection(OverlayThemeStyleSlateCard, preset == "slate");
}
private void RefreshOverlayServiceCards()
{
var service = (_settings.Settings.Llm.Service ?? "ollama").ToLowerInvariant();
SetOverlayCardSelection(OverlaySvcOllamaCard, service == "ollama");
SetOverlayCardSelection(OverlaySvcVllmCard, service == "vllm");
SetOverlayCardSelection(OverlaySvcGeminiCard, service == "gemini");
SetOverlayCardSelection(OverlaySvcClaudeCard, service is "claude" or "sigmoid");
}
private void RefreshOverlayModeButtons()
{
var llm = _settings.Settings.Llm;
var operationModeLabel = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode
? "사외 모드"
: "사내 모드";
var dataUsageLabel = _folderDataUsage switch
{
"active" => "적극 활용",
"passive" => "소극 활용",
_ => "활용하지 않음",
};
var permissionLabel = PermissionModeCatalog.ToDisplayLabel(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission));
var planLabel = PlanModeLabel(llm.PlanMode);
var reasoningLabel = ReasoningLabel(llm.AgentDecisionLevel);
BtnOverlayOperationMode.Content = GetQuickActionLabel("모드", operationModeLabel);
BtnOverlayFolderDataUsage.Content = GetQuickActionLabel("데이터", dataUsageLabel);
BtnOverlayPermission.Content = GetQuickActionLabel("권한", permissionLabel);
BtnOverlayPlanMode.Content = GetQuickActionLabel("계획", planLabel);
BtnOverlayReasoning.Content = GetQuickActionLabel("추론", reasoningLabel);
if (BtnOverlayFastMode != null)
{
BtnOverlayFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐");
ApplyQuickActionVisual(BtnOverlayFastMode, llm.FreeTierMode, "#ECFDF5", "#166534");
}
ApplyQuickActionVisual(BtnOverlayReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8");
ApplyQuickActionVisual(BtnOverlayPlanMode, !string.Equals(llm.PlanMode, "off", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#4338CA");
ApplyQuickActionVisual(BtnOverlayPermission,
!string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase),
"#FFF7ED",
"#C2410C");
UpdateDataUsageUI();
}
private static string GetQuickActionLabel(string title, string value)
=> $"{title} · {value}";
private void RefreshOverlayServiceFieldLabels(string service)
{
if (OverlayEndpointLabel == null || OverlayEndpointHint == null || OverlayApiKeyLabel == null || OverlayApiKeyHint == null)
return;
switch (service)
{
case "ollama":
OverlayEndpointLabel.Text = "Ollama 서버 주소";
OverlayEndpointHint.Text = "사내 로컬 Ollama 기본 주소를 입력합니다.";
OverlayApiKeyLabel.Text = "Ollama API 키";
OverlayApiKeyHint.Text = "사내 게이트웨이를 쓰는 경우에만 입력합니다.";
break;
case "vllm":
OverlayEndpointLabel.Text = "vLLM 서버 주소";
OverlayEndpointHint.Text = "OpenAI 호환 엔드포인트 주소를 입력합니다.";
OverlayApiKeyLabel.Text = "vLLM API 키";
OverlayApiKeyHint.Text = "사내 인증 게이트웨이를 쓰는 경우에만 입력합니다.";
break;
case "gemini":
OverlayEndpointLabel.Text = "기본 서버 주소";
OverlayEndpointHint.Text = "Gemini는 기본 주소를 사용합니다. 비워두면 기본값을 사용합니다.";
OverlayApiKeyLabel.Text = "Gemini API 키";
OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다.";
break;
default:
OverlayEndpointLabel.Text = "기본 서버 주소";
OverlayEndpointHint.Text = "Claude는 기본 주소를 사용합니다. 비워두면 기본값을 사용합니다.";
OverlayApiKeyLabel.Text = "Claude API 키";
OverlayApiKeyHint.Text = "외부 호출에 필요한 키를 입력합니다.";
break;
}
}
private string GetOverlayServiceEndpoint(string service)
{
var llm = _settings.Settings.Llm;
return service switch
{
"ollama" => llm.OllamaEndpoint ?? "",
"vllm" => llm.VllmEndpoint ?? "",
"gemini" => llm.Endpoint ?? "",
"claude" or "sigmoid" => llm.Endpoint ?? "",
_ => llm.Endpoint ?? ""
};
}
private string GetOverlayServiceApiKey(string service)
{
var llm = _settings.Settings.Llm;
return service switch
{
"ollama" => llm.OllamaApiKey ?? "",
"vllm" => llm.VllmApiKey ?? "",
"gemini" => llm.GeminiApiKey ?? "",
"claude" or "sigmoid" => llm.ClaudeApiKey ?? "",
_ => llm.ApiKey ?? ""
};
}
private void BuildOverlayModelChips(string service)
{
if (OverlayModelChipPanel == null)
return;
OverlayModelChipPanel.Children.Clear();
foreach (var model in GetModelCandidates(service))
{
var captured = model.Id;
var isActive = string.Equals(model.Id, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase);
var border = new Border
{
Cursor = Cursors.Hand,
CornerRadius = new CornerRadius(8),
BorderThickness = new Thickness(1),
BorderBrush = isActive
? BrushFromHex("#C7D2FE")
: (TryFindResource("BorderColor") as Brush ?? Brushes.Gray),
Background = isActive
? BrushFromHex("#EEF2FF")
: Brushes.Transparent,
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 0, 8, 8),
Child = new StackPanel
{
Children =
{
new TextBlock
{
Text = model.Label,
FontSize = 11.5,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
Foreground = isActive
? BrushFromHex("#1D4ED8")
: (TryFindResource("PrimaryText") as Brush ?? Brushes.Black),
},
new TextBlock
{
Text = model.Id,
Margin = new Thickness(0, 2, 0, 0),
FontSize = 10,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
}
}
}
};
border.MouseEnter += (_, _) =>
{
if (!isActive)
border.Background = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
};
border.MouseLeave += (_, _) =>
{
if (!isActive)
border.Background = Brushes.Transparent;
};
border.MouseLeftButtonUp += (_, _) =>
{
CommitOverlayModelSelection(captured);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
};
OverlayModelChipPanel.Children.Add(border);
}
}
private void SetOverlayCardSelection(Border border, bool selected)
{
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
var normal = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
border.BorderBrush = selected ? accent : normal;
border.Background = selected
? (TryFindResource("HintBackground") as Brush ?? Brushes.Transparent)
: Brushes.Transparent;
}
private void OverlayThemeSystemCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentTheme = "system";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeLightCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentTheme = "light";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeDarkCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentTheme = "dark";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeStyleClawCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentThemePreset = "claw";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeStyleCodexCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentThemePreset = "codex";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeStyleSlateCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentThemePreset = "slate";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlaySvcOllamaCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("ollama");
private void OverlaySvcVllmCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("vllm");
private void OverlaySvcGeminiCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("gemini");
private void OverlaySvcClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetOverlayService("claude");
private void SetOverlayService(string service)
{
_settings.Settings.Llm.Service = service;
var llm = _settings.Settings.Llm;
var candidates = GetModelCandidates(service);
var preferredModel = service switch
{
"ollama" => llm.OllamaModel,
"vllm" => llm.VllmModel,
"gemini" => llm.GeminiModel,
_ => llm.ClaudeModel
};
llm.Model = !string.IsNullOrWhiteSpace(preferredModel)
? preferredModel
: candidates.FirstOrDefault().Id ?? llm.Model;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
}
private void BtnOverlayOperationMode_Click(object sender, RoutedEventArgs e)
{
var next = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode
? OperationModePolicy.InternalMode
: OperationModePolicy.ExternalMode;
if (!PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요."))
return;
_settings.Settings.OperationMode = next;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void BtnOverlayFolderDataUsage_Click(object sender, RoutedEventArgs e)
{
_folderDataUsage = _folderDataUsage switch
{
"none" => "passive",
"passive" => "active",
_ => "none",
};
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void BtnOpenFullSettings_Click(object sender, RoutedEventArgs e)
{
if (System.Windows.Application.Current is App app)
app.OpenSettingsFromChat();
}
private bool PromptOverlayPasswordDialog(string title, string header, string message)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
var dlg = new Window
{
Title = title,
Width = 340,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent,
ShowInTaskbar = false,
};
var border = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(12),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(20),
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock { Text = header, FontSize = 15, FontWeight = FontWeights.SemiBold, Foreground = fgBrush, Margin = new Thickness(0, 0, 0, 12) });
stack.Children.Add(new TextBlock { Text = message, FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 6) });
var pwBox = new PasswordBox
{
FontSize = 14,
Padding = new Thickness(8, 6, 8, 6),
Background = itemBg,
Foreground = fgBrush,
BorderBrush = borderBrush,
PasswordChar = '*',
};
stack.Children.Add(pwBox);
var btnRow = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 16, 0, 0) };
var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) };
cancelBtn.Click += (_, _) => dlg.DialogResult = false;
btnRow.Children.Add(cancelBtn);
var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true };
okBtn.Click += (_, _) =>
{
if (pwBox.Password == UnifiedAdminPassword)
dlg.DialogResult = true;
else
{
pwBox.Clear();
pwBox.Focus();
}
};
btnRow.Children.Add(okBtn);
stack.Children.Add(btnRow);
border.Child = stack;
dlg.Content = border;
dlg.Loaded += (_, _) => pwBox.Focus();
return dlg.ShowDialog() == true;
}
private static int ParseOverlayInt(string? text, int fallback, int min, int max)
{
if (!int.TryParse(text, out var value))
value = fallback;
return Math.Clamp(value, min, max);
}
private void BtnInlineSettingsClose_Click(object sender, RoutedEventArgs e)
=> InlineSettingsPanel.IsOpen = false;
private void CmbInlineService_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isInlineSettingsSyncing || CmbInlineService.SelectedItem is not ComboBoxItem serviceItem || serviceItem.Tag is not string service)
return;
var llm = _settings.Settings.Llm;
llm.Service = service;
var candidates = GetModelCandidates(service);
if (candidates.Count > 0 && !candidates.Any(m => m.Id == llm.Model))
llm.Model = candidates[0].Id;
_settings.Save();
_appState.LoadFromSettings(_settings);
UpdateModelLabel();
RefreshInlineSettingsPanel();
}
private void CmbInlineModel_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isInlineSettingsSyncing || CmbInlineModel.SelectedItem is not ComboBoxItem modelItem || modelItem.Tag is not string modelId)
return;
_settings.Settings.Llm.Model = modelId;
_settings.Save();
_appState.LoadFromSettings(_settings);
UpdateModelLabel();
RefreshInlineSettingsPanel();
}
private void BtnInlineFastMode_Click(object sender, RoutedEventArgs e)
{
_settings.Settings.Llm.FreeTierMode = !_settings.Settings.Llm.FreeTierMode;
_settings.Save();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
RefreshOverlayVisualState(loadDeferredInputs: false);
}
private void BtnInlineReasoning_Click(object sender, RoutedEventArgs e)
{
var llm = _settings.Settings.Llm;
llm.AgentDecisionLevel = NextReasoning(llm.AgentDecisionLevel);
_settings.Save();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
RefreshOverlayVisualState(loadDeferredInputs: false);
}
private void BtnInlinePlanMode_Click(object sender, RoutedEventArgs e)
{
var llm = _settings.Settings.Llm;
llm.PlanMode = NextPlanMode(llm.PlanMode);
_settings.Save();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
RefreshOverlayVisualState(loadDeferredInputs: false);
}
private void BtnInlinePermission_Click(object sender, RoutedEventArgs e)
{
var llm = _settings.Settings.Llm;
llm.FilePermission = NextPermission(llm.FilePermission);
_settings.Save();
_appState.LoadFromSettings(_settings);
UpdatePermissionUI();
SaveConversationSettings();
RefreshInlineSettingsPanel();
RefreshOverlayVisualState(loadDeferredInputs: false);
}
private void BtnInlineSkill_Click(object sender, RoutedEventArgs e)
{
var llm = _settings.Settings.Llm;
llm.EnableSkillSystem = !llm.EnableSkillSystem;
if (llm.EnableSkillSystem)
{
SkillService.EnsureSkillFolder();
SkillService.LoadSkills(llm.SkillsFolderPath);
UpdateConditionalSkillActivation(reset: true);
}
_settings.Save();
_appState.LoadFromSettings(_settings);
RefreshInlineSettingsPanel();
if (llm.EnableSkillSystem)
OpenCommandSkillBrowser("/");
}
private void BtnInlineCommandBrowser_Click(object sender, RoutedEventArgs e)
=> OpenCommandSkillBrowser("/");
private void BtnInlineMcp_Click(object sender, RoutedEventArgs e)
{
var app = System.Windows.Application.Current as App;
app?.OpenSettingsFromChat();
}
private void BtnNewChat_Click(object sender, RoutedEventArgs e)
{
StartNewConversation();
InputBox.Focus();
}
public void ResumeConversation(string conversationId)
{
var conv = _storage.Load(conversationId);
if (conv != null)
{
var targetTab = NormalizeTabName(conv.Tab);
if (!string.Equals(_activeTab, targetTab, StringComparison.OrdinalIgnoreCase))
{
StopStreamingIfActive();
SaveCurrentTabConversationId();
PersistPerTabUiState();
_activeTab = targetTab;
RestorePerTabUiState();
UpdateTabUI();
if (string.Equals(targetTab, "Chat", StringComparison.OrdinalIgnoreCase))
TabChat.IsChecked = true;
else if (string.Equals(targetTab, "Cowork", StringComparison.OrdinalIgnoreCase))
TabCowork.IsChecked = true;
else if (TabCode.IsEnabled)
TabCode.IsChecked = true;
}
lock (_convLock)
{
conv.Tab = targetTab;
_currentConversation = ChatSession?.SetCurrentConversation(targetTab, conv, _storage) ?? conv;
SyncTabConversationIdsFromSession();
}
SaveLastConversations();
UpdateChatTitle();
RefreshConversationList();
RenderMessages();
UpdateFolderBar();
RefreshDraftQueueUi();
}
InputBox.Focus();
}
private static string NormalizeTabName(string? tab)
{
var normalized = (tab ?? "").Trim();
if (string.IsNullOrEmpty(normalized))
return "Chat";
if (normalized.Contains("코워크", StringComparison.OrdinalIgnoreCase))
return "Cowork";
var canonical = new string(normalized
.Where(char.IsLetterOrDigit)
.ToArray())
.ToLowerInvariant();
if (canonical is "cowork" or "coworkcode" or "coworkcodetab")
return "Cowork";
if (normalized.Contains("코드", StringComparison.OrdinalIgnoreCase)
|| canonical is "code" or "codetab")
return "Code";
return "Chat";
}
public void StartNewAndFocus()
{
StartNewConversation();
InputBox.Focus();
}
private void BtnDeleteAll_Click(object sender, RoutedEventArgs e)
{
var result = CustomMessageBox.Show(
"저장된 모든 대화 내역을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.",
"대화 전체 삭제",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result != MessageBoxResult.Yes) return;
_storage.DeleteAll();
lock (_convLock)
{
ChatSession?.ClearCurrentConversation(_activeTab);
_currentConversation = null;
SyncTabConversationIdsFromSession();
}
MessagePanel.Children.Clear();
EmptyState.Visibility = Visibility.Visible;
UpdateChatTitle();
RefreshConversationList();
}
// ─── 미리보기 패널 (탭 기반) ─────────────────────────────────────────────
private static readonly HashSet<string> _previewableExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".md", ".txt", ".csv", ".json", ".xml", ".log",
};
/// <summary>열려 있는 프리뷰 탭 목록 (파일 경로 기준).</summary>
private readonly List<string> _previewTabs = new();
private string? _activePreviewTab;
private void TryShowPreview(string filePath)
{
if (string.IsNullOrEmpty(filePath) || !System.IO.File.Exists(filePath))
return;
// 별도 커스텀 창으로 미리보기 (WebView2 HWND airspace 문제 근본 해결)
PreviewWindow.ShowPreview(filePath, _selectedMood);
}
private void ShowPreviewPanel(string filePath)
{
// 탭에 없으면 추가
if (!_previewTabs.Contains(filePath, StringComparer.OrdinalIgnoreCase))
_previewTabs.Add(filePath);
_activePreviewTab = filePath;
// 패널 열기
if (PreviewColumn.Width.Value < 100)
{
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
}
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
BtnPreviewToggle.Visibility = Visibility.Visible;
RebuildPreviewTabs();
LoadPreviewContent(filePath);
}
/// <summary>탭 바 UI를 다시 구성합니다.</summary>
private void RebuildPreviewTabs()
{
PreviewTabPanel.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 _previewTabs)
{
var fileName = System.IO.Path.GetFileName(tabPath);
var isActive = string.Equals(tabPath, _activePreviewTab, StringComparison.OrdinalIgnoreCase);
var tabBorder = new Border
{
Background = isActive
? new SolidColorBrush(Color.FromArgb(0x15, 0xFF, 0xFF, 0xFF))
: Brushes.Transparent,
BorderBrush = isActive ? accentBrush : Brushes.Transparent,
BorderThickness = new Thickness(0, 0, 0, isActive ? 2 : 0),
Padding = new Thickness(8, 6, 4, 6),
Cursor = Cursors.Hand,
MaxWidth = _previewTabs.Count <= 3 ? 200 : (_previewTabs.Count <= 5 ? 140 : 100),
};
var tabContent = new StackPanel { Orientation = Orientation.Horizontal };
// 파일명
tabContent.Children.Add(new TextBlock
{
Text = fileName,
FontSize = 11,
Foreground = isActive ? primaryText : secondaryText,
FontWeight = isActive ? FontWeights.SemiBold : FontWeights.Normal,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = tabBorder.MaxWidth - 30,
ToolTip = tabPath,
});
// 닫기 버튼 (x) — 활성 탭은 항상 표시, 비활성 탭은 호버 시에만 표시
var closeFg = isActive ? primaryText : secondaryText;
var closeBtnText = new TextBlock
{
Text = "\uE711",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = closeFg,
VerticalAlignment = VerticalAlignment.Center,
};
var closeBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(3),
Padding = new Thickness(3, 2, 3, 2),
Margin = new Thickness(5, 0, 0, 0),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
// 비활성 탭은 초기에 숨김, 활성 탭은 항상 표시
Visibility = isActive ? Visibility.Visible : Visibility.Hidden,
Child = closeBtnText,
};
var closePath = tabPath;
closeBtn.MouseEnter += (s, _) =>
{
if (s is Border b)
{
b.Background = new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0x50, 0x50));
if (b.Child is TextBlock tb) tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0x60, 0x60));
}
};
closeBtn.MouseLeave += (s, _) =>
{
if (s is Border b)
{
b.Background = Brushes.Transparent;
if (b.Child is TextBlock tb) tb.Foreground = closeFg;
}
};
closeBtn.Tag = "close"; // 닫기 버튼 식별용
closeBtn.MouseLeftButtonUp += (_, e) =>
{
e.Handled = true; // 부모 탭 클릭 이벤트 차단
ClosePreviewTab(closePath);
};
tabContent.Children.Add(closeBtn);
tabBorder.Child = tabContent;
// 탭 클릭 → 활성화 (MouseLeftButtonUp 사용: 닫기 버튼의 PreviewMouseLeftButtonDown보다 늦게 실행되어 충돌 방지)
var clickPath = tabPath;
tabBorder.MouseLeftButtonUp += (_, e) =>
{
if (e.Handled) return;
e.Handled = true;
_activePreviewTab = clickPath;
RebuildPreviewTabs();
LoadPreviewContent(clickPath);
};
// 우클릭 → 컨텍스트 메뉴
var ctxPath = tabPath;
tabBorder.MouseRightButtonUp += (_, e) =>
{
e.Handled = true;
ShowPreviewTabContextMenu(ctxPath);
};
// 더블클릭 → 별도 창에서 보기
var dblPath = tabPath;
tabBorder.MouseLeftButtonDown += (_, e) =>
{
if (e.Handled) return;
if (e.ClickCount == 2)
{
e.Handled = true;
OpenPreviewPopupWindow(dblPath);
}
};
// 호버 효과 — 비활성 탭에서 배경 강조 + 닫기 버튼 표시
var capturedIsActive = isActive;
var capturedCloseBtn = closeBtn;
tabBorder.MouseEnter += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF));
// 비활성 탭도 호버 시 닫기 버튼 표시
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Visible;
};
tabBorder.MouseLeave += (s, _) =>
{
if (s is Border b && !capturedIsActive)
b.Background = Brushes.Transparent;
// 비활성 탭 호버 해제 시 닫기 버튼 숨김
if (!capturedIsActive)
capturedCloseBtn.Visibility = Visibility.Hidden;
};
PreviewTabPanel.Children.Add(tabBorder);
// 탭 사이 구분선
if (tabPath != _previewTabs[^1])
{
PreviewTabPanel.Children.Add(new Border
{
Width = 1, Height = 14,
Background = borderBrush,
Margin = new Thickness(0, 4, 0, 4),
VerticalAlignment = VerticalAlignment.Center,
});
}
}
}
private void ClosePreviewTab(string filePath)
{
_previewTabs.Remove(filePath);
if (_previewTabs.Count == 0)
{
HidePreviewPanel();
return;
}
// 닫힌 탭이 활성 탭이면 마지막 탭으로 전환
if (string.Equals(filePath, _activePreviewTab, StringComparison.OrdinalIgnoreCase))
{
_activePreviewTab = _previewTabs[^1];
LoadPreviewContent(_activePreviewTab);
}
RebuildPreviewTabs();
}
private async void LoadPreviewContent(string filePath)
{
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
// 모든 콘텐츠 숨기기
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
PreviewEmpty.Visibility = Visibility.Collapsed;
if (!System.IO.File.Exists(filePath))
{
PreviewEmpty.Text = "파일을 찾을 수 없습니다";
PreviewEmpty.Visibility = Visibility.Visible;
return;
}
try
{
switch (ext)
{
case ".html":
case ".htm":
await EnsureWebViewInitializedAsync();
PreviewWebView.Source = new Uri(filePath);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".csv":
LoadCsvPreview(filePath);
PreviewDataGrid.Visibility = Visibility.Visible;
break;
case ".md":
await EnsureWebViewInitializedAsync();
var mdText = System.IO.File.ReadAllText(filePath);
if (mdText.Length > 50000) mdText = mdText[..50000];
var mdHtml = Services.Agent.TemplateService.RenderMarkdownToHtml(mdText, _selectedMood);
PreviewWebView.NavigateToString(mdHtml);
PreviewWebView.Visibility = Visibility.Visible;
break;
case ".txt":
case ".json":
case ".xml":
case ".log":
var text = System.IO.File.ReadAllText(filePath);
if (text.Length > 50000) text = text[..50000] + "\n\n... (이후 생략)";
PreviewTextBlock.Text = text;
PreviewTextScroll.Visibility = Visibility.Visible;
break;
default:
PreviewEmpty.Text = "미리보기할 수 없는 파일 형식입니다";
PreviewEmpty.Visibility = Visibility.Visible;
break;
}
}
catch (Exception ex)
{
PreviewTextBlock.Text = $"미리보기 오류: {ex.Message}";
PreviewTextScroll.Visibility = Visibility.Visible;
}
}
private bool _webViewInitialized;
private static readonly string WebView2DataFolder =
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "WebView2");
private async Task EnsureWebViewInitializedAsync()
{
if (_webViewInitialized) return;
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await PreviewWebView.EnsureCoreWebView2Async(env);
_webViewInitialized = true;
}
catch (Exception ex)
{
Services.LogService.Warn($"WebView2 초기화 실패: {ex.Message}");
}
}
private void LoadCsvPreview(string filePath)
{
try
{
var lines = System.IO.File.ReadAllLines(filePath);
if (lines.Length == 0) return;
var dt = new System.Data.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);
}
PreviewDataGrid.ItemsSource = dt.DefaultView;
}
catch (Exception ex)
{
PreviewTextBlock.Text = $"CSV 로드 오류: {ex.Message}";
PreviewTextScroll.Visibility = Visibility.Visible;
PreviewDataGrid.Visibility = Visibility.Collapsed;
}
}
private static string[] ParseCsvLine(string line)
{
var fields = new System.Collections.Generic.List<string>();
var current = new System.Text.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 HidePreviewPanel()
{
_previewTabs.Clear();
_activePreviewTab = null;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewWebView.Visibility = Visibility.Collapsed;
PreviewTextScroll.Visibility = Visibility.Collapsed;
PreviewDataGrid.Visibility = Visibility.Collapsed;
try { if (_webViewInitialized) PreviewWebView.CoreWebView2?.NavigateToString("<html></html>"); } catch { }
}
/// <summary>프리뷰 탭 바 클릭 시 WebView2에서 포커스를 회수 (HWND airspace 문제 방지).</summary>
private void PreviewTabBar_PreviewMouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
// WebView2가 포커스를 잡고 있으면 WPF 버튼 클릭이 무시될 수 있으므로 포커스를 강제 이동
if (PreviewWebView.IsFocused || PreviewWebView.IsKeyboardFocusWithin)
{
var border = sender as Border;
border?.Focus();
}
}
private void BtnClosePreview_Click(object sender, RoutedEventArgs e)
{
HidePreviewPanel();
BtnPreviewToggle.Visibility = Visibility.Collapsed;
}
private void BtnPreviewToggle_Click(object sender, RoutedEventArgs e)
{
if (PreviewPanel.Visibility == Visibility.Visible)
{
// 숨기기 (탭은 유지)
PreviewPanel.Visibility = Visibility.Collapsed;
PreviewSplitter.Visibility = Visibility.Collapsed;
PreviewColumn.Width = new GridLength(0);
SplitterColumn.Width = new GridLength(0);
}
else if (_previewTabs.Count > 0)
{
// 다시 열기
PreviewPanel.Visibility = Visibility.Visible;
PreviewSplitter.Visibility = Visibility.Visible;
PreviewColumn.Width = new GridLength(420);
SplitterColumn.Width = new GridLength(5);
RebuildPreviewTabs();
if (_activePreviewTab != null) LoadPreviewContent(_activePreviewTab);
}
}
private void BtnOpenExternal_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_activePreviewTab) || !System.IO.File.Exists(_activePreviewTab))
return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = _activePreviewTab,
UseShellExecute = true,
});
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"외부 프로그램 실행 오류: {ex.Message}");
}
}
/// <summary>프리뷰 탭 우클릭 컨텍스트 메뉴를 표시합니다.</summary>
private Popup? _previewTabPopup;
private void ShowPreviewTabContextMenu(string filePath)
{
// 기존 팝업 닫기
if (_previewTabPopup != null) _previewTabPopup.IsOpen = false;
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var stack = new StackPanel();
void AddItem(string icon, string iconColor, string label, Action action)
{
var itemBorder = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 7, 16, 7),
Cursor = Cursors.Hand,
};
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12, Foreground = string.IsNullOrEmpty(iconColor)
? secondaryText
: new SolidColorBrush((Color)ColorConverter.ConvertFromString(iconColor)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 8, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 13, Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
itemBorder.Child = sp;
itemBorder.MouseEnter += (s, _) => { if (s is Border b) b.Background = hoverBg; };
itemBorder.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
itemBorder.MouseLeftButtonUp += (_, _) =>
{
_previewTabPopup!.IsOpen = false;
action();
};
stack.Children.Add(itemBorder);
}
void AddSeparator()
{
stack.Children.Add(new Border
{
Height = 1,
Background = borderBrush,
Margin = new Thickness(8, 3, 8, 3),
});
}
AddItem("\uE8A7", "#64B5F6", "외부 프로그램으로 열기", () =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = filePath, UseShellExecute = true,
});
}
catch { }
});
AddItem("\uE838", "#FFB74D", "파일 위치 열기", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); }
catch { }
});
AddItem("\uE8A7", "#81C784", "별도 창에서 보기", () => OpenPreviewPopupWindow(filePath));
AddSeparator();
AddItem("\uE8C8", "", "경로 복사", () =>
{
try { Clipboard.SetText(filePath); } catch { }
});
AddSeparator();
AddItem("\uE711", "#EF5350", "이 탭 닫기", () => ClosePreviewTab(filePath));
if (_previewTabs.Count > 1)
{
AddItem("\uE8BB", "#EF5350", "다른 탭 모두 닫기", () =>
{
var keep = filePath;
_previewTabs.RemoveAll(p => !string.Equals(p, keep, StringComparison.OrdinalIgnoreCase));
_activePreviewTab = keep;
RebuildPreviewTabs();
LoadPreviewContent(keep);
});
}
var popupBorder = new Border
{
Background = bg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(4, 6, 4, 6),
MinWidth = 180,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, Opacity = 0.4, ShadowDepth = 4,
Color = Colors.Black,
},
Child = stack,
};
_previewTabPopup = new Popup
{
Child = popupBorder,
Placement = PlacementMode.MousePoint,
StaysOpen = false,
AllowsTransparency = true,
PopupAnimation = PopupAnimation.Fade,
};
_previewTabPopup.IsOpen = true;
}
/// <summary>프리뷰를 별도 팝업 창에서 엽니다.</summary>
private void OpenPreviewPopupWindow(string filePath)
{
if (!System.IO.File.Exists(filePath)) return;
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
var fileName = System.IO.Path.GetFileName(filePath);
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var win = new Window
{
Title = $"미리보기 — {fileName}",
Width = 900,
Height = 700,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Background = bg,
};
FrameworkElement content;
switch (ext)
{
case ".html":
case ".htm":
var wv = new Microsoft.Web.WebView2.Wpf.WebView2();
wv.Loaded += async (_, _) =>
{
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await wv.EnsureCoreWebView2Async(env);
wv.Source = new Uri(filePath);
}
catch { }
};
content = wv;
break;
case ".md":
var mdWv = new Microsoft.Web.WebView2.Wpf.WebView2();
var mdMood = _selectedMood;
mdWv.Loaded += async (_, _) =>
{
try
{
var env = await Microsoft.Web.WebView2.Core.CoreWebView2Environment.CreateAsync(
userDataFolder: WebView2DataFolder);
await mdWv.EnsureCoreWebView2Async(env);
var mdSrc = System.IO.File.ReadAllText(filePath);
if (mdSrc.Length > 100000) mdSrc = mdSrc[..100000];
var html = Services.Agent.TemplateService.RenderMarkdownToHtml(mdSrc, mdMood);
mdWv.NavigateToString(html);
}
catch { }
};
content = mdWv;
break;
case ".csv":
var dg = new System.Windows.Controls.DataGrid
{
AutoGenerateColumns = true,
IsReadOnly = true,
Background = Brushes.Transparent,
Foreground = Brushes.White,
BorderThickness = new Thickness(0),
FontSize = 12,
};
try
{
var lines = System.IO.File.ReadAllLines(filePath);
if (lines.Length > 0)
{
var dt = new System.Data.DataTable();
var headers = ParseCsvLine(lines[0]);
foreach (var h in headers) dt.Columns.Add(h);
for (int i = 1; i < Math.Min(lines.Length, 1001); i++)
{
var vals = ParseCsvLine(lines[i]);
var row = dt.NewRow();
for (int j = 0; j < Math.Min(vals.Length, dt.Columns.Count); j++)
row[j] = vals[j];
dt.Rows.Add(row);
}
dg.ItemsSource = dt.DefaultView;
}
}
catch { }
content = dg;
break;
default:
var text = System.IO.File.ReadAllText(filePath);
if (text.Length > 100000) text = text[..100000] + "\n\n... (이후 생략)";
var sv = new ScrollViewer
{
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
Padding = new Thickness(20),
Content = new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap,
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
Foreground = fg,
},
};
content = sv;
break;
}
win.Content = content;
win.Show();
}
// ─── 에이전트 스티키 진행률 바 ──────────────────────────────────────────
private DateTime _progressStartTime;
private DispatcherTimer? _progressElapsedTimer;
private void UpdateAgentProgressBar(AgentEvent evt)
{
switch (evt.Type)
{
case AgentEventType.Planning when evt.Steps is { Count: > 0 }:
ShowStickyProgress(evt.Steps.Count);
break;
case AgentEventType.StepStart when evt.StepTotal > 0:
UpdateStickyProgress(evt.StepCurrent, evt.StepTotal, evt.Summary);
break;
case AgentEventType.Complete:
HideStickyProgress();
break;
}
}
private void ShowStickyProgress(int totalSteps)
{
_progressStartTime = DateTime.Now;
AgentProgressBar.Visibility = Visibility.Visible;
ProgressIcon.Text = "\uE768"; // play
ProgressStepLabel.Text = $"작업 준비 중... (0/{totalSteps})";
ProgressPercent.Text = "0%";
ProgressElapsed.Text = "0:00";
ProgressFill.Width = 0;
// 경과 시간 타이머
_progressElapsedTimer?.Stop();
_progressElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_progressElapsedTimer.Tick += (_, _) =>
{
var elapsed = DateTime.Now - _progressStartTime;
ProgressElapsed.Text = elapsed.TotalHours >= 1
? elapsed.ToString(@"h\:mm\:ss")
: elapsed.ToString(@"m\:ss");
};
_progressElapsedTimer.Start();
}
private void UpdateStickyProgress(int currentStep, int totalSteps, string stepDescription)
{
if (AgentProgressBar.Visibility != Visibility.Visible) return;
var pct = totalSteps > 0 ? (double)currentStep / totalSteps : 0;
ProgressStepLabel.Text = $"{stepDescription} ({currentStep}/{totalSteps})";
ProgressPercent.Text = $"{(int)(pct * 100)}%";
// 프로그레스 바 너비 애니메이션
var parentBorder = ProgressFill.Parent as Border;
if (parentBorder != null)
{
var targetWidth = parentBorder.ActualWidth * pct;
var anim = new System.Windows.Media.Animation.DoubleAnimation(
ProgressFill.Width, targetWidth, TimeSpan.FromMilliseconds(300))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase(),
};
ProgressFill.BeginAnimation(WidthProperty, anim);
}
}
private void HideStickyProgress()
{
_progressElapsedTimer?.Stop();
_progressElapsedTimer = null;
if (AgentProgressBar.Visibility != Visibility.Visible) return;
// 완료 표시 후 페이드아웃
ProgressIcon.Text = "\uE930"; // check
ProgressStepLabel.Text = "작업 완료";
ProgressPercent.Text = "100%";
// 프로그레스 바 100%
var parentBorder = ProgressFill.Parent as Border;
if (parentBorder != null)
{
var anim = new System.Windows.Media.Animation.DoubleAnimation(
ProgressFill.Width, parentBorder.ActualWidth, TimeSpan.FromMilliseconds(200));
ProgressFill.BeginAnimation(WidthProperty, anim);
}
// 3초 후 숨기기
var hideTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
hideTimer.Tick += (_, _) =>
{
hideTimer.Stop();
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300));
fadeOut.Completed += (_, _) =>
{
AgentProgressBar.Visibility = Visibility.Collapsed;
AgentProgressBar.Opacity = 1;
ProgressFill.BeginAnimation(WidthProperty, null);
ProgressFill.Width = 0;
};
AgentProgressBar.BeginAnimation(UIElement.OpacityProperty, fadeOut);
};
hideTimer.Start();
}
// ─── 파일 탐색기 ──────────────────────────────────────────────────────
private static readonly HashSet<string> _ignoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea", ".vscode",
"__pycache__", ".mypy_cache", ".pytest_cache", "dist", "build",
".cache", ".next", ".nuxt", "coverage", ".terraform",
};
private DispatcherTimer? _fileBrowserRefreshTimer;
private void ToggleFileBrowser()
{
if (FileBrowserPanel.Visibility == Visibility.Visible)
{
FileBrowserPanel.Visibility = Visibility.Collapsed;
_settings.Settings.Llm.ShowFileBrowser = false;
}
else
{
FileBrowserPanel.Visibility = Visibility.Visible;
_settings.Settings.Llm.ShowFileBrowser = true;
BuildFileTree();
}
_settings.Save();
}
private void BtnFileBrowserRefresh_Click(object sender, RoutedEventArgs e) => BuildFileTree();
private void BtnFileBrowserOpenFolder_Click(object sender, RoutedEventArgs e)
{
var folder = GetCurrentWorkFolder();
if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder)) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = folder, UseShellExecute = true }); } catch { }
}
private void BtnFileBrowserClose_Click(object sender, RoutedEventArgs e)
{
FileBrowserPanel.Visibility = Visibility.Collapsed;
}
private void BuildFileTree()
{
FileTreeView.Items.Clear();
var folder = GetCurrentWorkFolder();
if (string.IsNullOrEmpty(folder) || !System.IO.Directory.Exists(folder))
{
FileTreeView.Items.Add(new TreeViewItem { Header = "작업 폴더를 선택하세요", IsEnabled = false });
return;
}
FileBrowserTitle.Text = $"파일 탐색기 — {System.IO.Path.GetFileName(folder)}";
var count = 0;
PopulateDirectory(new System.IO.DirectoryInfo(folder), FileTreeView.Items, 0, ref count);
}
private void PopulateDirectory(System.IO.DirectoryInfo dir, ItemCollection items, int depth, ref int count)
{
if (depth > 4 || count > 200) return;
// 디렉터리
try
{
foreach (var subDir in dir.GetDirectories().OrderBy(d => d.Name))
{
if (count > 200) break;
if (_ignoredDirs.Contains(subDir.Name) || subDir.Name.StartsWith('.')) continue;
count++;
var dirItem = new TreeViewItem
{
Header = CreateFileTreeHeader("\uED25", subDir.Name, null),
Tag = subDir.FullName,
IsExpanded = depth < 1,
};
// 지연 로딩: 더미 자식 → 펼칠 때 실제 로드
if (depth < 3)
{
dirItem.Items.Add(new TreeViewItem { Header = "로딩 중..." }); // 더미
var capturedDir = subDir;
var capturedDepth = depth;
dirItem.Expanded += (s, _) =>
{
if (s is TreeViewItem ti && ti.Items.Count == 1 && ti.Items[0] is TreeViewItem d && d.Header?.ToString() == "로딩 중...")
{
ti.Items.Clear();
int c = 0;
PopulateDirectory(capturedDir, ti.Items, capturedDepth + 1, ref c);
}
};
}
else
{
PopulateDirectory(subDir, dirItem.Items, depth + 1, ref count);
}
items.Add(dirItem);
}
}
catch { }
// 파일
try
{
foreach (var file in dir.GetFiles().OrderBy(f => f.Name))
{
if (count > 200) break;
count++;
var ext = file.Extension.ToLowerInvariant();
var icon = GetFileIcon(ext);
var size = FormatFileSize(file.Length);
var fileItem = new TreeViewItem
{
Header = CreateFileTreeHeader(icon, file.Name, size),
Tag = file.FullName,
};
// 더블클릭 → 프리뷰
var capturedPath = file.FullName;
fileItem.MouseDoubleClick += (s, e) =>
{
e.Handled = true;
TryShowPreview(capturedPath);
};
// 우클릭 → 컨텍스트 메뉴 (MouseRightButtonUp에서 열어야 Popup이 바로 닫히지 않음)
fileItem.MouseRightButtonUp += (s, e) =>
{
e.Handled = true;
if (s is TreeViewItem ti) ti.IsSelected = true;
ShowFileTreeContextMenu(capturedPath);
};
items.Add(fileItem);
}
}
catch { }
}
private static StackPanel CreateFileTreeHeader(string icon, string name, string? sizeText)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0x9C, 0xA3, 0xAF)),
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 5, 0),
});
sp.Children.Add(new TextBlock
{
Text = name,
FontSize = 11.5,
VerticalAlignment = VerticalAlignment.Center,
});
if (sizeText != null)
{
sp.Children.Add(new TextBlock
{
Text = $" {sizeText}",
FontSize = 10,
Foreground = new SolidColorBrush(Color.FromRgb(0x6B, 0x72, 0x80)),
VerticalAlignment = VerticalAlignment.Center,
});
}
return sp;
}
private static string GetFileIcon(string ext) => ext switch
{
".html" or ".htm" => "\uEB41",
".xlsx" or ".xls" => "\uE9F9",
".docx" or ".doc" => "\uE8A5",
".pdf" => "\uEA90",
".csv" => "\uE80A",
".md" => "\uE70B",
".json" or ".xml" => "\uE943",
".png" or ".jpg" or ".jpeg" or ".gif" or ".svg" or ".webp" => "\uEB9F",
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" => "\uE943",
".bat" or ".cmd" or ".ps1" or ".sh" => "\uE756",
".txt" or ".log" => "\uE8A5",
_ => "\uE7C3",
};
private static string FormatFileSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
_ => $"{bytes / (1024.0 * 1024.0):F1} MB",
};
private void ShowFileTreeContextMenu(string filePath)
{
var bg = TryFindResource("LauncherBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E));
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBg = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xE7, 0x4C, 0x3C));
var popup = new Popup
{
StaysOpen = false, AllowsTransparency = true, PopupAnimation = PopupAnimation.Fade,
Placement = PlacementMode.MousePoint,
};
var panel = new StackPanel { Margin = new Thickness(2) };
var container = new Border
{
Background = bg, BorderBrush = borderBrush, BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10), Padding = new Thickness(6), MinWidth = 200,
Effect = new System.Windows.Media.Effects.DropShadowEffect
{
BlurRadius = 16, ShadowDepth = 4, Opacity = 0.3,
Color = Colors.Black, Direction = 270,
},
Child = panel,
};
popup.Child = container;
void AddItem(string icon, string label, Action action, Brush? labelColor = null, Brush? iconColor = null)
{
var sp = new StackPanel { Orientation = Orientation.Horizontal };
sp.Children.Add(new TextBlock
{
Text = icon, FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 13, Foreground = iconColor ?? secondaryText,
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 10, 0),
});
sp.Children.Add(new TextBlock
{
Text = label, FontSize = 12.5, Foreground = labelColor ?? primaryText,
VerticalAlignment = VerticalAlignment.Center,
});
var item = new Border
{
Child = sp, Background = Brushes.Transparent,
CornerRadius = new CornerRadius(7), Cursor = Cursors.Hand,
Padding = new Thickness(10, 8, 14, 8), Margin = new Thickness(0, 1, 0, 1),
};
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 += (_, _) => { popup.IsOpen = false; action(); };
panel.Children.Add(item);
}
void AddSep()
{
panel.Children.Add(new Border
{
Height = 1, Margin = new Thickness(10, 4, 10, 4),
Background = borderBrush, Opacity = 0.3,
});
}
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
if (_previewableExtensions.Contains(ext))
AddItem("\uE8A1", "미리보기", () => ShowPreviewPanel(filePath));
AddItem("\uE8A7", "외부 프로그램으로 열기", () =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = filePath, UseShellExecute = true }); } catch { }
});
AddItem("\uED25", "폴더에서 보기", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{filePath}\""); } catch { }
});
AddItem("\uE8C8", "경로 복사", () =>
{
try { Clipboard.SetText(filePath); ShowToast("경로 복사됨"); } catch { }
});
AddSep();
// 이름 변경
AddItem("\uE8AC", "이름 변경", () =>
{
var dir = System.IO.Path.GetDirectoryName(filePath) ?? "";
var oldName = System.IO.Path.GetFileName(filePath);
var dlg = new Views.InputDialog("이름 변경", "새 파일 이름:", oldName) { Owner = this };
if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.ResponseText))
{
var newPath = System.IO.Path.Combine(dir, dlg.ResponseText.Trim());
try
{
System.IO.File.Move(filePath, newPath);
BuildFileTree();
ShowToast($"이름 변경: {dlg.ResponseText.Trim()}");
}
catch (Exception ex) { ShowToast($"이름 변경 실패: {ex.Message}", "\uE783"); }
}
});
// 삭제
AddItem("\uE74D", "삭제", () =>
{
var result = CustomMessageBox.Show(
$"파일을 삭제하시겠습니까?\n{System.IO.Path.GetFileName(filePath)}",
"파일 삭제 확인", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (result == MessageBoxResult.Yes)
{
try
{
System.IO.File.Delete(filePath);
BuildFileTree();
ShowToast("파일 삭제됨");
}
catch (Exception ex) { ShowToast($"삭제 실패: {ex.Message}", "\uE783"); }
}
}, dangerBrush, dangerBrush);
// Dispatcher로 열어야 MouseRightButtonUp 후 바로 닫히지 않음
Dispatcher.BeginInvoke(() => { popup.IsOpen = true; },
System.Windows.Threading.DispatcherPriority.Input);
}
/// <summary>에이전트가 파일 생성 시 파일 탐색기를 자동 새로고침합니다.</summary>
private void RefreshFileTreeIfVisible()
{
if (FileBrowserPanel.Visibility != Visibility.Visible) return;
// 디바운스: 500ms 내 중복 호출 방지
_fileBrowserRefreshTimer?.Stop();
_fileBrowserRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_fileBrowserRefreshTimer.Tick += (_, _) =>
{
_fileBrowserRefreshTimer.Stop();
BuildFileTree();
};
_fileBrowserRefreshTimer.Start();
}
// ─── 하단 상태바 ──────────────────────────────────────────────────────
private System.Windows.Media.Animation.Storyboard? _statusSpinStoryboard;
private void UpdateStatusBar(AgentEvent evt)
{
var toolLabel = evt.ToolName switch
{
"file_read" or "document_read" => "파일 읽기",
"file_write" => "파일 쓰기",
"file_edit" => "파일 수정",
"html_create" => "HTML 생성",
"xlsx_create" => "Excel 생성",
"docx_create" => "Word 생성",
"csv_create" => "CSV 생성",
"md_create" => "Markdown 생성",
"folder_map" => "폴더 탐색",
"glob" => "파일 검색",
"grep" => "내용 검색",
"process" => "명령 실행",
_ => evt.ToolName,
};
switch (evt.Type)
{
case AgentEventType.Thinking:
SetStatus("생각 중...", spinning: true);
break;
case AgentEventType.Planning:
SetStatus($"계획 수립 중 — {evt.StepTotal}단계", spinning: true);
break;
case AgentEventType.PermissionRequest:
SetStatus($"권한 확인 중: {toolLabel}", spinning: false);
break;
case AgentEventType.PermissionGranted:
SetStatus($"권한 승인됨: {toolLabel}", spinning: false);
break;
case AgentEventType.PermissionDenied:
SetStatus($"권한 거부됨: {toolLabel}", spinning: false);
StopStatusAnimation();
break;
case AgentEventType.Decision:
SetStatus(GetDecisionStatusText(evt.Summary), spinning: IsDecisionPending(evt.Summary));
break;
case AgentEventType.ToolCall:
SetStatus($"{toolLabel} 실행 중...", spinning: true);
break;
case AgentEventType.ToolResult:
SetStatus(evt.Success ? $"{toolLabel} 완료" : $"{toolLabel} 실패", spinning: false);
break;
case AgentEventType.StepStart:
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] {TruncateForStatus(evt.Summary)}", spinning: true);
break;
case AgentEventType.StepDone:
SetStatus($"[{evt.StepCurrent}/{evt.StepTotal}] 단계 완료", spinning: true);
break;
case AgentEventType.SkillCall:
SetStatus($"스킬 실행 중: {TruncateForStatus(evt.Summary)}", spinning: true);
break;
case AgentEventType.Complete:
SetStatus("작업 완료", spinning: false);
StopStatusAnimation();
break;
case AgentEventType.Error:
SetStatus("오류 발생", spinning: false);
StopStatusAnimation();
break;
case AgentEventType.Paused:
SetStatus("⏸ 일시정지", spinning: false);
break;
case AgentEventType.Resumed:
SetStatus("▶ 재개됨", spinning: true);
break;
}
}
private void SetStatus(string text, bool spinning)
{
if (StatusLabel != null) StatusLabel.Text = text;
if (spinning) StartStatusAnimation();
}
private static (string icon, string label, string bgHex, string fgHex) GetDecisionBadgeMeta(string? summary)
{
if (IsDecisionApproved(summary))
return ("\uE73E", "계획 승인", "#ECFDF5", "#059669");
if (IsDecisionRejected(summary))
return ("\uE783", "계획 반려", "#FEF2F2", "#DC2626");
return ("\uE70F", "계획 확인", "#FFF7ED", "#C2410C");
}
private static (string icon, string label, string bgHex, string fgHex) GetPermissionBadgeMeta(string? toolName, bool pending)
{
var tool = toolName?.Trim().ToLowerInvariant() ?? "";
if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell"))
return pending
? ("\uE756", "명령 권한 요청", "#FEF2F2", "#DC2626")
: ("\uE73E", "명령 권한 허용", "#ECFDF5", "#059669");
if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http"))
return pending
? ("\uE774", "네트워크 권한 요청", "#FFF7ED", "#C2410C")
: ("\uE73E", "네트워크 권한 허용", "#ECFDF5", "#059669");
if (tool.Contains("file"))
return pending
? ("\uE8A5", "파일 권한 요청", "#FFF7ED", "#C2410C")
: ("\uE73E", "파일 권한 허용", "#ECFDF5", "#059669");
return pending
? ("\uE897", "권한 요청", "#FFF7ED", "#C2410C")
: ("\uE73E", "권한 허용", "#ECFDF5", "#059669");
}
private static bool IsDecisionPending(string? summary)
{
var text = summary?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(text))
return true;
return text.Contains("확인 대기", StringComparison.OrdinalIgnoreCase)
|| text.Contains("승인 대기", StringComparison.OrdinalIgnoreCase);
}
private static bool IsDecisionApproved(string? summary)
{
var text = summary?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(text))
return false;
return text.Contains("계획 승인", StringComparison.OrdinalIgnoreCase);
}
private static bool IsDecisionRejected(string? summary)
{
var text = summary?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(text))
return false;
return text.Contains("계획 반려", StringComparison.OrdinalIgnoreCase)
|| text.Contains("수정 요청", StringComparison.OrdinalIgnoreCase)
|| text.Contains("취소", StringComparison.OrdinalIgnoreCase);
}
private static string GetDecisionStatusText(string? summary)
{
if (IsDecisionPending(summary))
return "계획 승인 대기 중";
if (IsDecisionApproved(summary))
return "계획 승인됨 — 실행 시작";
if (IsDecisionRejected(summary))
return "계획 반려됨 — 계획 재작성";
return string.IsNullOrWhiteSpace(summary) ? "사용자 의사결정 대기 중" : TruncateForStatus(summary);
}
private void StartStatusAnimation()
{
if (_statusSpinStoryboard != null) return;
var anim = new System.Windows.Media.Animation.DoubleAnimation
{
From = 0, To = 360,
Duration = TimeSpan.FromSeconds(2),
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
};
_statusSpinStoryboard = new System.Windows.Media.Animation.Storyboard();
System.Windows.Media.Animation.Storyboard.SetTarget(anim, StatusDiamond);
System.Windows.Media.Animation.Storyboard.SetTargetProperty(anim,
new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)"));
_statusSpinStoryboard.Children.Add(anim);
_statusSpinStoryboard.Begin();
}
private void StopStatusAnimation()
{
_statusSpinStoryboard?.Stop();
_statusSpinStoryboard = null;
}
private void SetStatusIdle()
{
StopStatusAnimation();
if (StatusLabel != null) StatusLabel.Text = "대기 중";
if (StatusElapsed != null) StatusElapsed.Text = "";
if (StatusTokens != null) StatusTokens.Text = "";
RefreshContextUsageVisual();
ScheduleGitBranchRefresh(250);
}
private void UpdateStatusTokens(int inputTokens, int outputTokens)
{
if (StatusTokens == null) return;
var llm = _settings.Settings.Llm;
var (inCost, outCost) = Services.TokenEstimator.EstimateCost(
inputTokens, outputTokens, llm.Service, llm.Model);
var totalCost = inCost + outCost;
var costText = totalCost > 0 ? $" · {Services.TokenEstimator.FormatCost(totalCost)}" : "";
StatusTokens.Text = $"↑{Services.TokenEstimator.Format(inputTokens)} ↓{Services.TokenEstimator.Format(outputTokens)}{costText}";
RefreshContextUsageVisual();
}
private void BtnCompactNow_Click(object sender, RoutedEventArgs e)
{
if (_isStreaming)
{
SetStatus("응답 생성 중에는 압축을 실행할 수 없습니다", spinning: false);
return;
}
_ = ExecuteManualCompactAsync("/compact", _activeTab);
}
private async void BtnGitBranch_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrWhiteSpace(_currentGitBranchName) || GitBranchPopup == null || GitBranchItems == null)
return;
if (GitBranchPopup.IsOpen)
{
GitBranchPopup.IsOpen = false;
return;
}
await RefreshGitBranchStatusAsync();
_gitBranchSearchText = "";
if (GitBranchSearchBox != null)
GitBranchSearchBox.Text = "";
BuildGitBranchPopup();
GitBranchPopup.IsOpen = true;
GitBranchSearchBox?.Focus();
}
private void GitBranchSearchBox_TextChanged(object sender, TextChangedEventArgs e)
{
_gitBranchSearchText = GitBranchSearchBox?.Text?.Trim() ?? "";
if (GitBranchPopup?.IsOpen == true)
BuildGitBranchPopup();
}
private void TrackRecentGitBranch(string? branchName)
{
if (string.IsNullOrWhiteSpace(branchName))
return;
_recentGitBranches.RemoveAll(branch => string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase));
_recentGitBranches.Insert(0, branchName);
if (_recentGitBranches.Count > 6)
_recentGitBranches.RemoveRange(6, _recentGitBranches.Count - 6);
}
private void RefreshContextUsageVisual()
{
if (TokenUsageCard == null || TokenUsageArc == null || TokenUsagePercentText == null
|| TokenUsageSummaryText == null || TokenUsageHintText == null
|| TokenUsageThresholdMarker == null || CompactNowLabel == null)
return;
var llm = _settings.Settings.Llm;
var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000);
var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95);
var triggerRatio = triggerPercent / 100.0;
int messageTokens;
lock (_convLock)
messageTokens = _currentConversation?.Messages?.Count > 0
? Services.TokenEstimator.EstimateMessages(_currentConversation.Messages)
: 0;
var draftText = InputBox?.Text ?? "";
var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4;
var currentTokens = Math.Max(0, messageTokens + draftTokens);
var usageRatio = Services.TokenEstimator.GetContextUsage(currentTokens, maxContextTokens);
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
Brush progressBrush = accentBrush;
string summary;
string compactLabel;
if (usageRatio >= 1.0)
{
progressBrush = Brushes.IndianRed;
summary = "컨텍스트 한도 초과";
compactLabel = "지금 압축";
}
else if (usageRatio >= triggerRatio)
{
progressBrush = Brushes.DarkOrange;
summary = llm.EnableProactiveContextCompact ? "곧 자동 압축" : "압축 임계 도달";
compactLabel = "압축 권장";
}
else if (usageRatio >= triggerRatio * 0.7)
{
progressBrush = Brushes.Goldenrod;
summary = "컨텍스트 사용 증가";
compactLabel = "미리 압축";
}
else
{
summary = "컨텍스트 여유";
compactLabel = "압축";
}
TokenUsageArc.Stroke = progressBrush;
TokenUsageThresholdMarker.Fill = progressBrush;
var percentText = $"{Math.Round(usageRatio * 100):0}%";
TokenUsagePercentText.Text = percentText;
TokenUsageSummaryText.Text = $"컨텍스트 {percentText}";
TokenUsageHintText.Text = $"{Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}";
CompactNowLabel.Text = compactLabel;
var compactHistory = _lastCompactionAt.HasValue && _lastCompactionBeforeTokens.HasValue && _lastCompactionAfterTokens.HasValue
? $"\n최근 압축: {(_lastCompactionWasAutomatic ? "" : "")} · {_lastCompactionAt.Value:HH:mm:ss}\n" +
$"절감: {_lastCompactionBeforeTokens.Value:N0} → {_lastCompactionAfterTokens.Value:N0} tokens " +
$"(-{Math.Max(0, _lastCompactionBeforeTokens.Value - _lastCompactionAfterTokens.Value):N0}, " +
$"{Services.TokenEstimator.Format(_lastCompactionBeforeTokens.Value)} → {Services.TokenEstimator.Format(_lastCompactionAfterTokens.Value)})"
: "";
TokenUsageCard.ToolTip =
$"상태: {summary}\n" +
$"사용량: {currentTokens:N0} / {maxContextTokens:N0} tokens ({percentText})\n" +
$"간단 표기: {Services.TokenEstimator.Format(currentTokens)} / {Services.TokenEstimator.Format(maxContextTokens)}\n" +
$"자동 압축 시작: {triggerPercent}%\n" +
$"현재 입력 초안 포함" +
compactHistory;
UpdateCircularUsageArc(TokenUsageArc, usageRatio, 18, 18, 14);
PositionThresholdMarker(TokenUsageThresholdMarker, triggerRatio, 18, 18, 14, 3);
}
private static void UpdateCircularUsageArc(System.Windows.Shapes.Path path, double ratio, double centerX, double centerY, double radius)
{
ratio = Math.Clamp(ratio, 0, 0.9999);
if (ratio <= 0)
{
path.Data = Geometry.Empty;
return;
}
var start = GetCirclePoint(centerX, centerY, radius, -90);
var end = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90);
var figure = new PathFigure
{
StartPoint = start,
IsClosed = false,
IsFilled = false,
};
figure.Segments.Add(new ArcSegment
{
Point = end,
Size = new Size(radius, radius),
SweepDirection = SweepDirection.Clockwise,
IsLargeArc = ratio >= 0.5,
});
path.Data = new PathGeometry(new[] { figure });
}
private static void PositionThresholdMarker(FrameworkElement marker, double ratio, double centerX, double centerY, double radius, double halfSize)
{
ratio = Math.Clamp(ratio, 0, 0.9999);
var point = GetCirclePoint(centerX, centerY, radius, ratio * 360 - 90);
Canvas.SetLeft(marker, point.X - halfSize);
Canvas.SetTop(marker, point.Y - halfSize);
}
private static Point GetCirclePoint(double centerX, double centerY, double radius, double angleDegrees)
{
var radians = angleDegrees * Math.PI / 180.0;
return new Point(
centerX + radius * Math.Cos(radians),
centerY + radius * Math.Sin(radians));
}
private void RecordCompactionStats(int beforeTokens, int afterTokens, bool wasAutomatic)
{
_lastCompactionBeforeTokens = Math.Max(0, beforeTokens);
_lastCompactionAfterTokens = Math.Max(0, afterTokens);
_lastCompactionAt = DateTime.Now;
_lastCompactionWasAutomatic = wasAutomatic;
}
private void ScheduleGitBranchRefresh(int delayMs = 400)
{
if (BtnGitBranch == null)
return;
_gitRefreshTimer.Stop();
_gitRefreshTimer.Interval = TimeSpan.FromMilliseconds(Math.Max(100, delayMs));
_gitRefreshTimer.Start();
}
private async Task RefreshGitBranchStatusAsync()
{
var folder = GetCurrentWorkFolder();
if (_activeTab == "Chat" || string.IsNullOrWhiteSpace(folder))
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
return;
}
var gitRoot = ResolveGitRoot(folder);
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitRoot) || string.IsNullOrWhiteSpace(gitPath))
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
return;
}
_gitStatusRefreshCts?.Cancel();
_gitStatusRefreshCts?.Dispose();
_gitStatusRefreshCts = new CancellationTokenSource();
var ct = _gitStatusRefreshCts.Token;
try
{
var branchTask = RunGitAsync(gitPath, gitRoot, new[] { "rev-parse", "--abbrev-ref", "HEAD" }, ct);
var statusTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct);
var diffTask = RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat", "HEAD" }, ct);
var branchesTask = RunGitAsync(gitPath, gitRoot, new[] { "branch", "--format", "%(refname:short)" }, ct);
var upstreamTask = RunGitAsync(gitPath, gitRoot, new[] { "status", "-sb" }, ct);
await Task.WhenAll(branchTask, statusTask, diffTask, branchesTask, upstreamTask);
var branchResult = await branchTask;
var statusResult = await statusTask;
var diffResult = await diffTask;
var branchesResult = await branchesTask;
var upstreamResult = await upstreamTask;
if (branchResult.ExitCode != 0 || statusResult.ExitCode != 0)
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
return;
}
var branchName = branchResult.StdOut.Trim();
if (string.IsNullOrWhiteSpace(branchName))
branchName = "detached";
var fileCount = statusResult.StdOut
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Length;
var branches = branchesResult.ExitCode == 0
? branchesResult.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x.Equals(branchName, StringComparison.OrdinalIgnoreCase) ? "" : x, StringComparer.OrdinalIgnoreCase)
.ToList()
: new List<string>();
var diffText = diffResult.ExitCode == 0 ? diffResult.StdOut : "";
if (string.IsNullOrWhiteSpace(diffText))
{
var fallbackDiff = await RunGitAsync(gitPath, gitRoot, new[] { "diff", "--shortstat" }, ct);
diffText = fallbackDiff.ExitCode == 0 ? fallbackDiff.StdOut : "";
}
var insertions = ParseGitShortStat(diffText, "insertion");
var deletions = ParseGitShortStat(diffText, "deletion");
var filesText = fileCount == 0 && insertions == 0 && deletions == 0
? "깨끗함"
: $"{fileCount:N0}개 파일";
var addedText = insertions > 0 ? $"+{insertions:N0}" : "";
var deletedText = deletions > 0 ? $"-{deletions:N0}" : "";
var tooltip = $"브랜치: {branchName}\n작업 폴더: {gitRoot}\n변경 파일: {fileCount:N0}\n추가 라인: +{insertions:N0}\n삭제 라인: -{deletions:N0}";
_currentGitRoot = gitRoot;
_currentGitChangedFileCount = fileCount;
_currentGitInsertions = insertions;
_currentGitDeletions = deletions;
_currentGitBranches = branches;
_currentGitUpstreamStatus = ExtractGitUpstreamStatus(upstreamResult.StdOut);
UpdateGitBranchUi(branchName, filesText, addedText, deletedText, tooltip, Visibility.Visible);
}
catch (OperationCanceledException)
{
}
catch
{
UpdateGitBranchUi(null, "", "", "", "", Visibility.Collapsed);
}
}
private void UpdateGitBranchUi(string? branchName, string filesText, string addedText, string deletedText, string tooltip, Visibility visibility)
{
Dispatcher.Invoke(() =>
{
_currentGitBranchName = branchName;
_currentGitTooltip = tooltip;
if (BtnGitBranch != null)
{
BtnGitBranch.Visibility = visibility;
BtnGitBranch.ToolTip = string.IsNullOrWhiteSpace(tooltip) ? "현재 Git 브랜치 상태" : tooltip;
}
if (GitBranchLabel != null)
GitBranchLabel.Text = string.IsNullOrWhiteSpace(branchName) ? "브랜치 없음" : branchName;
if (GitBranchFilesText != null)
GitBranchFilesText.Text = filesText;
if (GitBranchAddedText != null)
GitBranchAddedText.Text = addedText;
if (GitBranchDeletedText != null)
GitBranchDeletedText.Text = deletedText;
if (GitBranchSeparator != null)
GitBranchSeparator.Visibility = visibility;
});
}
private static int ParseGitShortStat(string text, string unit)
{
if (string.IsNullOrWhiteSpace(text))
return 0;
var match = System.Text.RegularExpressions.Regex.Match(
text,
$@"(\d+)\s+{unit}",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success && int.TryParse(match.Groups[1].Value, out var value) ? value : 0;
}
private static string? ExtractGitUpstreamStatus(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() ?? "";
var start = firstLine.IndexOf('[', StringComparison.Ordinal);
var end = firstLine.IndexOf(']', StringComparison.Ordinal);
if (start >= 0 && end > start)
return firstLine[(start + 1)..end];
return null;
}
private void BuildGitBranchPopup()
{
if (GitBranchItems == null)
return;
GitBranchItems.Children.Clear();
var gitRoot = _currentGitRoot ?? ResolveGitRoot(GetCurrentWorkFolder());
var branchName = _currentGitBranchName ?? "detached";
var tooltip = _currentGitTooltip ?? "";
var fileText = GitBranchFilesText?.Text ?? "";
var addedText = GitBranchAddedText?.Text ?? "";
var deletedText = GitBranchDeletedText?.Text ?? "";
var query = (_gitBranchSearchText ?? "").Trim();
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
GitBranchItems.Children.Add(CreatePopupSummaryStrip(new[]
{
("브랜치", string.IsNullOrWhiteSpace(branchName) ? "없음" : branchName, "#F8FAFC", "#E2E8F0", "#475569"),
("파일", string.IsNullOrWhiteSpace(fileText) ? "0" : fileText, "#EFF6FF", "#BFDBFE", "#1D4ED8"),
("최근", _recentGitBranches.Count.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"),
}));
GitBranchItems.Children.Add(CreatePopupSectionLabel("현재 브랜치", new Thickness(8, 6, 8, 4)));
GitBranchItems.Children.Add(CreatePopupMenuRow(
"\uE943",
branchName,
string.IsNullOrWhiteSpace(fileText) ? "현재 브랜치" : fileText,
true,
accentBrush,
secondaryText,
primaryText,
() => { }));
if (!string.IsNullOrWhiteSpace(addedText) || !string.IsNullOrWhiteSpace(deletedText))
{
var stats = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(8, 2, 8, 8),
};
if (!string.IsNullOrWhiteSpace(addedText))
stats.Children.Add(CreateMetricPill(addedText, "#16A34A"));
if (!string.IsNullOrWhiteSpace(deletedText))
stats.Children.Add(CreateMetricPill(deletedText, "#DC2626"));
GitBranchItems.Children.Add(stats);
}
if (!string.IsNullOrWhiteSpace(gitRoot))
{
GitBranchItems.Children.Add(CreatePopupSectionLabel("저장소", new Thickness(8, 6, 8, 4)));
GitBranchItems.Children.Add(CreatePopupMenuRow(
"\uED25",
System.IO.Path.GetFileName(gitRoot.TrimEnd('\\', '/')),
gitRoot,
false,
accentBrush,
secondaryText,
primaryText,
() => { }));
}
if (!string.IsNullOrWhiteSpace(_currentGitUpstreamStatus))
{
GitBranchItems.Children.Add(CreatePopupMenuRow(
"\uE8AB",
"업스트림",
_currentGitUpstreamStatus!,
false,
accentBrush,
secondaryText,
primaryText,
() => { }));
}
GitBranchItems.Children.Add(CreatePopupSectionLabel("빠른 작업", new Thickness(8, 10, 8, 4)));
GitBranchItems.Children.Add(CreatePopupMenuRow(
"\uE8C8",
"상태 요약 복사",
"브랜치, 변경 파일, 추가/삭제 라인 복사",
false,
accentBrush,
secondaryText,
primaryText,
() =>
{
try { Clipboard.SetText(tooltip); } catch { }
GitBranchPopup.IsOpen = false;
}));
GitBranchItems.Children.Add(CreatePopupMenuRow(
"\uE72B",
"새로고침",
"Git 상태를 다시 조회합니다",
false,
accentBrush,
secondaryText,
primaryText,
async () =>
{
await RefreshGitBranchStatusAsync();
BuildGitBranchPopup();
}));
var filteredBranches = _currentGitBranches
.Where(branch => string.IsNullOrWhiteSpace(query)
|| branch.Contains(query, StringComparison.OrdinalIgnoreCase))
.Take(20)
.ToList();
var recentBranches = _recentGitBranches
.Where(branch => _currentGitBranches.Any(current => string.Equals(current, branch, StringComparison.OrdinalIgnoreCase)))
.Where(branch => string.IsNullOrWhiteSpace(query)
|| branch.Contains(query, StringComparison.OrdinalIgnoreCase))
.Take(5)
.ToList();
if (recentBranches.Count > 0)
{
var recentSectionLabel = string.IsNullOrWhiteSpace(query)
? $"최근 전환 · {recentBranches.Count}"
: $"최근 전환 · {recentBranches.Count}";
GitBranchItems.Children.Add(CreatePopupSectionLabel(recentSectionLabel, new Thickness(8, 10, 8, 4)));
foreach (var branch in recentBranches)
{
var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase);
GitBranchItems.Children.Add(CreatePopupMenuRow(
isCurrent ? "\uE73E" : "\uE8FD",
branch,
isCurrent ? "현재 브랜치" : "최근 사용 브랜치",
isCurrent,
accentBrush,
secondaryText,
primaryText,
isCurrent ? null : () => _ = SwitchGitBranchAsync(branch)));
}
}
if (_currentGitBranches.Count > 0)
{
var branchSectionLabel = string.IsNullOrWhiteSpace(query)
? $"브랜치 전환 · {_currentGitBranches.Count}"
: $"브랜치 전환 · {filteredBranches.Count}/{_currentGitBranches.Count}";
GitBranchItems.Children.Add(CreatePopupSectionLabel(branchSectionLabel, new Thickness(8, 10, 8, 4)));
foreach (var branch in filteredBranches)
{
if (recentBranches.Any(recent => string.Equals(recent, branch, StringComparison.OrdinalIgnoreCase)))
continue;
var isCurrent = string.Equals(branch, branchName, StringComparison.OrdinalIgnoreCase);
GitBranchItems.Children.Add(CreatePopupMenuRow(
isCurrent ? "\uE73E" : "\uE943",
branch,
isCurrent ? "현재 브랜치" : "이 브랜치로 전환",
isCurrent,
accentBrush,
secondaryText,
primaryText,
isCurrent ? null : () => _ = SwitchGitBranchAsync(branch)));
}
if (!string.IsNullOrWhiteSpace(query) && filteredBranches.Count == 0)
{
GitBranchItems.Children.Add(new TextBlock
{
Text = "검색 결과가 없습니다.",
FontSize = 11.5,
Foreground = secondaryText,
Margin = new Thickness(10, 6, 10, 10),
});
}
}
GitBranchItems.Children.Add(CreatePopupSectionLabel("브랜치 작업", new Thickness(8, 10, 8, 4)));
GitBranchItems.Children.Add(CreatePopupMenuRow(
"\uE710",
"새 브랜치 생성",
"현재 작업 기준으로 새 브랜치를 만들고 전환합니다",
false,
accentBrush,
secondaryText,
primaryText,
() => _ = CreateGitBranchAsync()));
}
private TextBlock CreatePopupSectionLabel(string text, Thickness? margin = null)
{
return new TextBlock
{
Text = text,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
Margin = margin ?? new Thickness(8, 8, 8, 4),
};
}
private UIElement CreatePopupSummaryStrip(IEnumerable<(string Label, string Value, string BgHex, string BorderHex, string FgHex)> items)
{
var wrap = new WrapPanel
{
Margin = new Thickness(8, 6, 8, 6),
};
foreach (var item in items)
{
wrap.Children.Add(CreateMetricPill($"{item.Label} {item.Value}", item.FgHex, item.BgHex, item.BorderHex));
}
return wrap;
}
private Border CreateMetricPill(string text, string colorHex)
=> CreateMetricPill(text, colorHex, $"{colorHex}18", $"{colorHex}44");
private Border CreateMetricPill(string text, string colorHex, string bgHex, string borderHex)
{
return new Border
{
Background = BrushFromHex(bgHex),
BorderBrush = BrushFromHex(borderHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 0),
Child = new TextBlock
{
Text = text,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(colorHex),
}
};
}
private Border CreateFlatPopupRow(string icon, string title, string description, string colorHex, bool clickable, Action? onClick)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var hoverBrush = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray;
var borderColor = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var border = new Border
{
Background = Brushes.Transparent,
BorderBrush = borderColor,
BorderThickness = new Thickness(0, 0, 0, 1),
Padding = new Thickness(8, 9, 8, 9),
Cursor = clickable ? Cursors.Hand : Cursors.Arrow,
Focusable = clickable,
};
KeyboardNavigation.SetIsTabStop(border, clickable);
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.Children.Add(new TextBlock
{
Text = icon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = BrushFromHex(colorHex),
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 1, 10, 0),
});
var textStack = new StackPanel();
textStack.Children.Add(new TextBlock
{
Text = title,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
if (!string.IsNullOrWhiteSpace(description))
{
textStack.Children.Add(new TextBlock
{
Text = description,
FontSize = 10.5,
Foreground = secondaryText,
Margin = new Thickness(0, 2, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
Grid.SetColumn(textStack, 1);
grid.Children.Add(textStack);
if (clickable)
{
var chevron = new TextBlock
{
Text = "\uE76C",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 10,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(8, 0, 0, 0),
};
Grid.SetColumn(chevron, 2);
grid.Children.Add(chevron);
}
border.Child = grid;
if (clickable && onClick != null)
{
border.MouseEnter += (_, _) => border.Background = hoverBrush;
border.MouseLeave += (_, _) => border.Background = Brushes.Transparent;
border.MouseLeftButtonUp += (_, _) => onClick();
border.KeyDown += (_, ke) =>
{
if (ke.Key is Key.Enter or Key.Space)
{
ke.Handled = true;
onClick();
}
};
}
return border;
}
private async Task SwitchGitBranchAsync(string branchName)
{
if (string.IsNullOrWhiteSpace(branchName) || string.IsNullOrWhiteSpace(_currentGitRoot))
return;
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return;
try
{
var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", branchName }, CancellationToken.None);
if (result.ExitCode != 0)
{
CustomMessageBox.Show($"브랜치 전환에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
TrackRecentGitBranch(branchName);
GitBranchPopup.IsOpen = false;
await RefreshGitBranchStatusAsync();
SetStatus($"브랜치 전환: {branchName}", spinning: false);
}
catch (Exception ex)
{
CustomMessageBox.Show($"브랜치 전환 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async Task CreateGitBranchAsync()
{
if (string.IsNullOrWhiteSpace(_currentGitRoot))
return;
var dlg = new Views.InputDialog("새 브랜치 생성", "브랜치 이름:", "", "feature/my-change") { Owner = this };
if (dlg.ShowDialog() != true)
return;
var branchName = (dlg.ResponseText ?? "").Trim();
if (string.IsNullOrWhiteSpace(branchName))
return;
var gitPath = FindGitExecutablePath();
if (string.IsNullOrWhiteSpace(gitPath))
return;
try
{
var result = await RunGitAsync(gitPath, _currentGitRoot!, new[] { "switch", "-c", branchName }, CancellationToken.None);
if (result.ExitCode != 0)
{
CustomMessageBox.Show($"브랜치 생성에 실패했습니다.\n{result.StdErr.Trim()}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
TrackRecentGitBranch(branchName);
GitBranchPopup.IsOpen = false;
await RefreshGitBranchStatusAsync();
SetStatus($"새 브랜치 생성: {branchName}", spinning: false);
}
catch (Exception ex)
{
CustomMessageBox.Show($"브랜치 생성 중 오류가 발생했습니다.\n{ex.Message}", "Git 브랜치", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private static string TruncateForStatus(string? text, int max = 40)
{
if (string.IsNullOrEmpty(text)) return "";
return text.Length <= max ? text : text[..max] + "…";
}
private string BuildComposerDraftText()
{
var rawText = InputBox?.Text?.Trim() ?? "";
return _slashPalette.ActiveCommand != null
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
: rawText;
}
private static string InferDraftKind(string text, string? explicitKind = null)
{
var trimmed = text?.Trim() ?? "";
var requestedKind = explicitKind?.Trim().ToLowerInvariant();
if (requestedKind is "followup" or "steering")
return requestedKind;
if (trimmed.StartsWith("/", StringComparison.OrdinalIgnoreCase))
return "command";
if (requestedKind is "direct" or "message")
return requestedKind;
if (trimmed.StartsWith("steer:", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("@steer ", StringComparison.OrdinalIgnoreCase) ||
trimmed.StartsWith("조정:", StringComparison.OrdinalIgnoreCase))
return "steering";
return "message";
}
private void QueueComposerDraft(string priority = "next", string? explicitKind = null, bool startImmediatelyWhenIdle = true)
{
if (InputBox == null)
return;
var text = BuildComposerDraftText();
if (string.IsNullOrWhiteSpace(text))
return;
if (_isStreaming && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase))
priority = "next";
HideSlashChip(restoreText: false);
ClearPromptCardPlaceholder();
DraftQueueItem? queuedItem = null;
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
_currentConversation = (queuedItem = session.EnqueueDraft(
_activeTab,
text,
priority,
_storage,
InferDraftKind(text, explicitKind))) != null
? session.CurrentConversation
: _currentConversation;
}
InputBox.Clear();
InputBox.Focus();
RefreshDraftQueueUi();
if (queuedItem == null)
return;
if (!_isStreaming && startImmediatelyWhenIdle)
{
StartNextQueuedDraftIfAny(queuedItem.Id);
return;
}
var toast = queuedItem.Kind switch
{
"command" => "명령이 대기열에 추가되었습니다.",
"direct" => "직접 실행 요청이 대기열에 추가되었습니다.",
"steering" => "조정 요청이 대기열에 추가되었습니다.",
"followup" => "후속 작업이 대기열에 추가되었습니다.",
_ => "메시지가 대기열에 추가되었습니다.",
};
ShowToast(toast);
}
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
private Popup? _taskSummaryPopup;
private UIElement? _taskSummaryTarget;
private string _taskSummaryTaskFilter = "all";
private void BtnDraftEnqueue_Click(object sender, RoutedEventArgs e)
{
var text = InputBox?.Text?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(text))
return;
QueueComposerDraft(
priority: Keyboard.Modifiers.HasFlag(ModifierKeys.Control) ? "now" : "next",
explicitKind: "steering",
startImmediatelyWhenIdle: true);
}
private void BtnDraftEdit_Click(object sender, RoutedEventArgs e)
{
if (InputBox == null)
return;
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text?.Length ?? 0;
}
private void BtnDraftClear_Click(object sender, RoutedEventArgs e)
{
if (InputBox == null)
return;
InputBox.Clear();
InputBox.Focus();
RefreshDraftQueueUi();
}
private void RefreshDraftQueueUi()
{
if (DraftPreviewCard == null || DraftPreviewText == null || DraftQueuePanel == null || BtnDraftEnqueue == null)
return;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
_draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage);
}
var inputText = InputBox?.Text?.Trim() ?? "";
var hasInput = !string.IsNullOrWhiteSpace(inputText);
var summary = _appState.GetDraftQueueSummary(_activeTab);
var items = _appState.GetDraftQueueItems(_activeTab);
DraftPreviewCard.Visibility = hasInput ? Visibility.Visible : Visibility.Collapsed;
BtnDraftEnqueue.IsEnabled = hasInput;
DraftPreviewText.Text = hasInput
? $"{TruncateForStatus(inputText, 96)}{(summary.TotalCount > 0 ? $" · {summary.QueuedCount} · {summary.RunningCount}" : "")}"
: "";
RebuildDraftQueuePanel(items);
}
private void RebuildDraftQueuePanel(IReadOnlyList<DraftQueueItem> items)
{
if (DraftQueuePanel == null)
return;
DraftQueuePanel.Children.Clear();
var visibleItems = items
.OrderBy(GetDraftStateRank)
.ThenBy(GetDraftPriorityRank)
.ThenBy(x => x.CreatedAt)
.ToList();
if (visibleItems.Count == 0)
{
DraftQueuePanel.Visibility = Visibility.Collapsed;
return;
}
DraftQueuePanel.Visibility = Visibility.Visible;
var summary = _appState.GetDraftQueueSummary(_activeTab);
DraftQueuePanel.Children.Add(CreateDraftQueueSummaryStrip(summary));
const int maxPerSection = 3;
var runningItems = visibleItems
.Where(item => string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
.Take(maxPerSection)
.ToList();
var queuedItems = visibleItems
.Where(item => string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) && !IsDraftBlocked(item))
.Take(maxPerSection)
.ToList();
var blockedItems = visibleItems
.Where(IsDraftBlocked)
.Take(maxPerSection)
.ToList();
var completedItems = visibleItems
.Where(item => string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
.Take(maxPerSection)
.ToList();
var failedItems = visibleItems
.Where(item => string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase))
.Take(maxPerSection)
.ToList();
AddDraftQueueSection("실행 중", runningItems, summary.RunningCount);
AddDraftQueueSection("다음 작업", queuedItems, summary.QueuedCount);
AddDraftQueueSection("보류", blockedItems, summary.BlockedCount);
AddDraftQueueSection("완료", completedItems, summary.CompletedCount);
AddDraftQueueSection("실패", failedItems, summary.FailedCount);
if (summary.CompletedCount > 0 || summary.FailedCount > 0)
{
var footer = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 2, 0, 0),
};
if (summary.CompletedCount > 0)
footer.Children.Add(CreateDraftQueueActionButton($"완료 정리 {summary.CompletedCount}", ClearCompletedDrafts, BrushFromHex("#ECFDF5")));
if (summary.FailedCount > 0)
footer.Children.Add(CreateDraftQueueActionButton($"실패 정리 {summary.FailedCount}", ClearFailedDrafts, BrushFromHex("#FEF2F2")));
DraftQueuePanel.Children.Add(footer);
}
}
private void AddDraftQueueSection(string label, IReadOnlyList<DraftQueueItem> items, int totalCount)
{
if (DraftQueuePanel == null || totalCount <= 0)
return;
DraftQueuePanel.Children.Add(CreateDraftQueueSectionLabel($"{label} · {totalCount}"));
foreach (var item in items)
DraftQueuePanel.Children.Add(CreateDraftQueueCard(item));
if (totalCount > items.Count)
{
DraftQueuePanel.Children.Add(new TextBlock
{
Text = $"추가 항목 {totalCount - items.Count}개",
Margin = new Thickness(8, -2, 0, 8),
FontSize = 10.5,
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#7A7F87"),
});
}
}
private UIElement CreateDraftQueueSummaryStrip(AppStateService.DraftQueueSummaryState summary)
{
var wrap = new WrapPanel
{
Margin = new Thickness(0, 0, 0, 8),
};
if (summary.RunningCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("실행 중", summary.RunningCount.ToString(), "#EFF6FF", "#BFDBFE", "#1D4ED8"));
if (summary.QueuedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("다음", summary.QueuedCount.ToString(), "#F5F3FF", "#DDD6FE", "#6D28D9"));
if (summary.BlockedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("보류", summary.BlockedCount.ToString(), "#FFF7ED", "#FDBA74", "#C2410C"));
if (summary.CompletedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("완료", summary.CompletedCount.ToString(), "#ECFDF5", "#BBF7D0", "#166534"));
if (summary.FailedCount > 0)
wrap.Children.Add(CreateQueueSummaryPill("실패", summary.FailedCount.ToString(), "#FEF2F2", "#FECACA", "#991B1B"));
if (wrap.Children.Count == 0)
wrap.Children.Add(CreateQueueSummaryPill("대기열", "0", "#F8FAFC", "#E2E8F0", "#475569"));
return wrap;
}
private Border CreateQueueSummaryPill(string label, string value, string bgHex, string borderHex, string fgHex)
{
return new Border
{
Background = BrushFromHex(bgHex),
BorderBrush = BrushFromHex(borderHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(8, 3, 8, 3),
Margin = new Thickness(0, 0, 6, 0),
Child = new StackPanel
{
Orientation = Orientation.Horizontal,
Children =
{
new TextBlock
{
Text = label,
FontSize = 10,
Foreground = BrushFromHex(fgHex),
},
new TextBlock
{
Text = $" {value}",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(fgHex),
}
}
}
};
}
private TextBlock CreateDraftQueueSectionLabel(string text)
{
return new TextBlock
{
Text = text,
FontSize = 10.5,
FontWeight = FontWeights.SemiBold,
Margin = new Thickness(8, 0, 8, 6),
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B"),
};
}
private Border CreateDraftQueueCard(DraftQueueItem item)
{
var background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#F7F7F8");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E4E4E7");
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827");
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280");
var neutralSurface = BrushFromHex("#F5F6F8");
var (kindIcon, kindForeground) = GetDraftKindVisual(item);
var (stateBackground, stateBorder, stateForeground) = GetDraftStateBadgeColors(item);
var (priorityBackground, priorityBorder, priorityForeground) = GetDraftPriorityBadgeColors(item.Priority);
var container = new Border
{
Background = background,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 8),
};
var root = new Grid();
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
root.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
container.Child = root;
var left = new StackPanel();
Grid.SetColumn(left, 0);
root.Children.Add(left);
var header = new StackPanel
{
Orientation = Orientation.Horizontal,
};
header.Children.Add(new TextBlock
{
Text = kindIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = kindForeground,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 6, 0),
});
header.Children.Add(CreateDraftQueueBadge(GetDraftKindLabel(item), BrushFromHex("#F8FAFC"), borderBrush, kindForeground));
header.Children.Add(CreateDraftQueueBadge(GetDraftStateLabel(item), stateBackground, stateBorder, stateForeground));
header.Children.Add(CreateDraftQueueBadge(GetDraftPriorityLabel(item.Priority), priorityBackground, priorityBorder, priorityForeground));
left.Children.Add(header);
left.Children.Add(new TextBlock
{
Text = item.Text,
FontSize = 12.5,
Foreground = primaryText,
Margin = new Thickness(0, 6, 0, 0),
TextWrapping = TextWrapping.Wrap,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 520,
});
var meta = $"{item.CreatedAt:HH:mm}";
if (item.AttemptCount > 0)
meta += $" · 시도 {item.AttemptCount}";
if (item.NextRetryAt.HasValue && item.NextRetryAt.Value > DateTime.Now)
meta += $" · 재시도 {item.NextRetryAt.Value:HH:mm:ss}";
if (!string.IsNullOrWhiteSpace(item.LastError))
meta += $" · {TruncateForStatus(item.LastError, 36)}";
left.Children.Add(new TextBlock
{
Text = meta,
FontSize = 10.5,
Foreground = secondaryText,
Margin = new Thickness(0, 6, 0, 0),
});
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
VerticalAlignment = VerticalAlignment.Top,
};
Grid.SetColumn(actions, 1);
root.Children.Add(actions);
if (!string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase))
actions.Children.Add(CreateDraftQueueActionButton("실행", () => QueueDraftForImmediateRun(item.Id)));
if (string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ||
string.Equals(item.State, "completed", StringComparison.OrdinalIgnoreCase))
{
actions.Children.Add(CreateDraftQueueActionButton("대기", () => ResetDraftInQueue(item.Id), neutralSurface));
}
actions.Children.Add(CreateDraftQueueActionButton("삭제", () => RemoveDraftFromQueue(item.Id), neutralSurface));
return container;
}
private Border CreateDraftQueueBadge(string text, Brush background, Brush borderBrush, Brush foreground)
{
return new Border
{
Background = background,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(7, 2, 7, 2),
Margin = new Thickness(0, 0, 6, 0),
Child = new TextBlock
{
Text = text,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = foreground,
}
};
}
private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null)
{
var btn = new Button
{
Content = label,
Margin = new Thickness(6, 0, 0, 0),
Padding = new Thickness(10, 5, 10, 5),
MinWidth = 48,
FontSize = 11,
Background = background ?? BrushFromHex("#EEF2FF"),
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#D4D4D8"),
BorderThickness = new Thickness(1),
Foreground = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#111827"),
Cursor = Cursors.Hand,
};
btn.Click += (_, _) => onClick();
return btn;
}
private void QueueDraftForImmediateRun(string draftId)
{
if (_isStreaming)
{
ShowToast("현재 작업이 끝난 뒤 실행할 수 있습니다.", "\uE783");
return;
}
StartNextQueuedDraftIfAny(draftId);
}
private void ResetDraftInQueue(string draftId)
{
lock (_convLock)
{
var session = ChatSession;
if (session != null && session.ResetDraftToQueued(_activeTab, draftId, _storage))
_currentConversation = session.CurrentConversation;
}
RefreshDraftQueueUi();
}
private void RemoveDraftFromQueue(string draftId)
{
lock (_convLock)
{
var session = ChatSession;
if (session != null && session.RemoveDraft(_activeTab, draftId, _storage))
_currentConversation = session.CurrentConversation;
}
RefreshDraftQueueUi();
}
private void ClearCompletedDrafts()
{
int removed;
lock (_convLock)
{
removed = _draftQueueProcessor.ClearCompleted(ChatSession, _activeTab, _storage);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
}
RefreshDraftQueueUi();
if (removed > 0)
ShowToast($"완료된 대기열 {removed}개 정리됨");
}
private void ClearFailedDrafts()
{
int removed;
lock (_convLock)
{
removed = _draftQueueProcessor.ClearFailed(ChatSession, _activeTab, _storage);
_currentConversation = ChatSession?.CurrentConversation ?? _currentConversation;
}
RefreshDraftQueueUi();
if (removed > 0)
ShowToast($"실패한 대기열 {removed}개 정리됨");
}
private void StartNextQueuedDraftIfAny(string? preferredDraftId = null)
{
if (_isStreaming || InputBox == null)
return;
DraftQueueItem? next = null;
lock (_convLock)
{
var session = ChatSession;
next = _draftQueueProcessor.TryStartNext(session, _activeTab, _storage, preferredDraftId, _appState.TaskRuns);
if (next == null)
return;
_runningDraftId = next.Id;
_currentConversation = session?.CurrentConversation ?? _currentConversation;
}
InputBox.Text = next.Text;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
RefreshDraftQueueUi();
_ = SendMessageAsync();
}
private static int GetDraftStateRank(DraftQueueItem item)
=> string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0
: IsDraftBlocked(item) ? 1
: string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) ? 2
: string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ? 3
: 4;
private static int GetDraftPriorityRank(DraftQueueItem item)
=> item.Priority?.ToLowerInvariant() switch
{
"now" => 0,
"next" => 1,
_ => 2,
};
private static string GetDraftPriorityLabel(string? priority)
=> priority?.ToLowerInvariant() switch
{
"now" => "지금",
"later" => "나중",
_ => "다음",
};
private static string GetDraftKindLabel(DraftQueueItem item)
=> item.Kind?.ToLowerInvariant() switch
{
"followup" => "후속 작업",
"steering" => "조정",
"command" => "명령",
"direct" => "직접 실행",
_ => "메시지",
};
private (string Icon, Brush Foreground) GetDraftKindVisual(DraftQueueItem item)
=> item.Kind?.ToLowerInvariant() switch
{
"followup" => ("\uE8A5", BrushFromHex("#0F766E")),
"steering" => ("\uE7C3", BrushFromHex("#B45309")),
"command" => ("\uE756", BrushFromHex("#7C3AED")),
"direct" => ("\uE8A7", BrushFromHex("#2563EB")),
_ => ("\uE8BD", BrushFromHex("#475569")),
};
private static string GetDraftStateLabel(DraftQueueItem item)
=> IsDraftBlocked(item) ? "재시도 대기"
: item.State?.ToLowerInvariant() switch
{
"running" => "실행 중",
"failed" => "실패",
"completed" => "완료",
_ => "대기",
};
private Brush GetDraftStateBrush(DraftQueueItem item)
=> IsDraftBlocked(item) ? BrushFromHex("#B45309")
: item.State?.ToLowerInvariant() switch
{
"running" => BrushFromHex("#2563EB"),
"failed" => BrushFromHex("#DC2626"),
"completed" => BrushFromHex("#059669"),
_ => BrushFromHex("#7C3AED"),
};
private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item)
=> IsDraftBlocked(item)
? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C"))
: item.State?.ToLowerInvariant() switch
{
"running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")),
"failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
"completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
_ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")),
};
private static (Brush Background, Brush Border, Brush Foreground) GetDraftPriorityBadgeColors(string? priority)
=> priority?.ToLowerInvariant() switch
{
"now" => (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
"later" => (BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
_ => (BrushFromHex("#FEF3C7"), BrushFromHex("#FDE68A"), BrushFromHex("#92400E")),
};
private static bool IsDraftBlocked(DraftQueueItem item)
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase)
&& item.NextRetryAt.HasValue
&& item.NextRetryAt.Value > DateTime.Now;
private void OpenCommandSkillBrowser(string seedInput)
{
if (InputBox == null)
return;
if (!string.IsNullOrEmpty(_slashPalette.ActiveCommand))
HideSlashChip();
InputBox.Text = seedInput;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
}
private void BtnToggleExecutionLog_Click(object sender, RoutedEventArgs e)
{
bool visible;
lock (_convLock)
{
var session = _appState.ChatSession;
visible = session?.ToggleExecutionHistory(_activeTab, _storage) ?? !(_currentConversation?.ShowExecutionHistory ?? true);
if (session != null)
_currentConversation = session.CurrentConversation;
}
if (ExecutionLogLabel != null)
ExecutionLogLabel.Text = visible ? "실행 로그 켜짐" : "실행 로그 숨김";
if (ExecutionLogIcon != null)
ExecutionLogIcon.Text = visible ? "\uE946" : "\uE8F8";
RenderMessages();
}
private void RuntimeTaskSummary_Click(object sender, MouseButtonEventArgs e)
{
e.Handled = true;
_taskSummaryTarget = sender as UIElement ?? RuntimeActivityBadge;
ShowTaskSummaryPopup();
}
private void ShowTaskSummaryPopup()
{
if (_taskSummaryTarget == null)
return;
if (_taskSummaryPopup != null)
_taskSummaryPopup.IsOpen = false;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var popupBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
var panel = new StackPanel { Margin = new Thickness(2) };
panel.Children.Add(new TextBlock
{
Text = "작업 요약",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
Margin = new Thickness(10, 8, 10, 4),
});
panel.Children.Add(new TextBlock
{
Text = "현재 실행/권한/작업 흐름",
FontSize = 10,
Foreground = secondaryText,
Margin = new Thickness(10, 0, 10, 6),
});
var taskFilterRow = new WrapPanel
{
Margin = new Thickness(8, 0, 8, 8),
};
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("all", "전체"));
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("permission", "권한"));
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("queue", "대기"));
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("tool", "도구"));
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("subagent", "서브"));
taskFilterRow.Children.Add(CreateTaskSummaryFilterChip("hook", "훅"));
panel.Children.Add(taskFilterRow);
ChatConversation? currentConversation;
lock (_convLock) currentConversation = _currentConversation;
AddTaskSummaryObservabilitySections(panel, currentConversation);
if (!string.IsNullOrWhiteSpace(_appState.AgentRun.RunId))
{
var currentRun = new Border
{
Background = BrushFromHex("#F8FAFC"),
BorderBrush = BrushFromHex("#E2E8F0"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(8, 0, 8, 8),
Child = new StackPanel
{
Children =
{
new TextBlock
{
Text = $"현재 실행 run {ShortRunId(_appState.AgentRun.RunId)}",
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
},
new TextBlock
{
Text = $"{GetRunStatusLabel(_appState.AgentRun.Status)} · iteration {_appState.AgentRun.LastIteration}",
Margin = new Thickness(0, 3, 0, 0),
Foreground = GetRunStatusBrush(_appState.AgentRun.Status),
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(_appState.AgentRun.Summary) ? "요약 없음" : _appState.AgentRun.Summary,
Margin = new Thickness(0, 4, 0, 0),
TextWrapping = TextWrapping.Wrap,
Foreground = Brushes.DimGray,
}
}
}
};
panel.Children.Add(currentRun);
}
var recentAgentRuns = _appState.GetRecentAgentRuns(3);
if (recentAgentRuns.Count > 0)
{
var latestFailedRun = _appState.GetLatestFailedRun();
if (latestFailedRun != null)
{
panel.Children.Add(new Border
{
Background = BrushFromHex("#FEF2F2"),
BorderBrush = BrushFromHex("#FECACA"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(8, 0, 8, 8),
Child = new StackPanel
{
Children =
{
new TextBlock
{
Text = $"최근 실패 원인 · run {ShortRunId(latestFailedRun.RunId)}",
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#991B1B"),
},
new TextBlock
{
Text = $"{latestFailedRun.UpdatedAt:HH:mm:ss} · iteration {latestFailedRun.LastIteration}",
Margin = new Thickness(0, 2, 0, 0),
Foreground = BrushFromHex("#B45309"),
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(latestFailedRun.Summary) ? "요약 없음" : latestFailedRun.Summary,
Margin = new Thickness(0, 4, 0, 0),
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
}
}
}
});
}
panel.Children.Add(new TextBlock
{
Text = "최근 에이전트 실행",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.DimGray,
Margin = new Thickness(10, 0, 10, 4),
});
foreach (var run in recentAgentRuns)
{
var runEvents = GetExecutionEventsForRun(run.RunId);
var runFilePaths = GetExecutionEventFilePaths(run.RunId);
var runDisplay = _appState.GetRunDisplay(run);
var runCardStack = new StackPanel
{
Children =
{
new TextBlock
{
Text = runDisplay.HeaderText,
FontWeight = FontWeights.SemiBold,
Foreground = GetRunStatusBrush(run.Status),
},
new TextBlock
{
Text = runDisplay.MetaText,
Margin = new Thickness(0, 2, 0, 0),
Foreground = secondaryText,
},
new TextBlock
{
Text = TruncateForStatus(runDisplay.SummaryText, 140),
Margin = new Thickness(0, 3, 0, 0),
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
}
}
};
if (runEvents.Count > 0 || runFilePaths.Count > 0)
{
var activitySummary = new StackPanel();
activitySummary.Children.Add(new TextBlock
{
Text = $"실행 로그 {runEvents.Count} · 관련 파일 {runFilePaths.Count}",
FontSize = 10,
Foreground = secondaryText,
});
if (!string.IsNullOrWhiteSpace(run.RunId))
{
var capturedRunId = run.RunId;
var timelineButton = CreateTaskSummaryActionButton(
"타임라인 보기",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) => ScrollToRunInTimeline(capturedRunId),
trailingMargin: false);
timelineButton.Margin = new Thickness(0, 6, 0, 0);
activitySummary.Children.Add(timelineButton);
}
runCardStack.Children.Add(new Border
{
Background = BrushFromHex("#F8FAFC"),
BorderBrush = BrushFromHex("#E2E8F0"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(0, 8, 0, 0),
Child = activitySummary
});
}
if (string.Equals(run.Status, "completed", StringComparison.OrdinalIgnoreCase))
{
var capturedRun = run;
var followUpButton = CreateTaskSummaryActionButton(
"후속 작업 큐에 넣기",
"#ECFDF5",
"#BBF7D0",
"#166534",
(_, _) => EnqueueFollowUpFromRun(capturedRun),
trailingMargin: false);
followUpButton.Margin = new Thickness(0, 8, 0, 0);
runCardStack.Children.Add(followUpButton);
}
if (string.Equals(run.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
{
var retryButton = CreateTaskSummaryActionButton(
"이 실행 다시 시도",
"#FEF2F2",
"#FCA5A5",
"#991B1B",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
RetryLastUserMessageFromConversation();
},
trailingMargin: false);
retryButton.Margin = new Thickness(0, 8, 0, 0);
runCardStack.Children.Add(retryButton);
}
panel.Children.Add(new Border
{
Background = Brushes.White,
BorderBrush = BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(9),
Padding = new Thickness(9, 6, 9, 6),
Margin = new Thickness(8, 0, 8, 6),
Child = runCardStack
});
}
if (_appState.GetLatestFailedRun() != null
&& CanRetryCurrentConversation())
{
var actionsPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(8, 2, 8, 8),
};
var retryButton = CreateTaskSummaryActionButton(
"마지막 요청 다시 시도",
"#EEF2FF",
"#C7D2FE",
"#3730A3",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
RetryLastUserMessageFromConversation();
});
actionsPanel.Children.Add(retryButton);
if (_appState.ActiveTasks.Count > 0)
{
var runningFilterButton = CreateTaskSummaryActionButton(
"진행 중 대화만 보기",
"#DBEAFE",
"#93C5FD",
"#1D4ED8",
(_, _) =>
{
_runningOnlyFilter = true;
UpdateConversationRunningFilterUi();
PersistConversationListPreferences();
RefreshConversationList();
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
},
trailingMargin: false);
runningFilterButton.Margin = new Thickness(2, 0, 0, 0);
actionsPanel.Children.Add(runningFilterButton);
}
panel.Children.Add(actionsPanel);
}
}
foreach (var task in FilterTaskSummaryItems(_appState.ActiveTasks).Take(6))
panel.Children.Add(BuildTaskSummaryCard(task, active: true));
foreach (var task in FilterTaskSummaryItems(_appState.RecentTasks).Take(6))
panel.Children.Add(BuildTaskSummaryCard(task, active: false));
if (!FilterTaskSummaryItems(_appState.ActiveTasks).Any() && !FilterTaskSummaryItems(_appState.RecentTasks).Any())
{
panel.Children.Add(new TextBlock
{
Text = "표시할 작업 이력이 없습니다.",
Margin = new Thickness(10, 2, 10, 8),
Foreground = secondaryText,
});
}
_taskSummaryPopup = new Popup
{
PlacementTarget = _taskSummaryTarget,
Placement = PlacementMode.Top,
AllowsTransparency = true,
StaysOpen = false,
PopupAnimation = PopupAnimation.Fade,
Child = new Border
{
Background = popupBackground,
BorderBrush = BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(6),
Child = new ScrollViewer
{
Content = panel,
MaxHeight = 340,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
}
}
};
_taskSummaryPopup.IsOpen = true;
}
private bool CanRetryCurrentConversation()
{
return !string.IsNullOrWhiteSpace(GetLastUserMessageFromConversation());
}
private List<ChatExecutionEvent> GetExecutionEventsForRun(string? runId, int take = 3)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, eventTake: take).Events;
}
private static bool IsBranchContextMessage(string? content)
{
return !string.IsNullOrWhiteSpace(content)
&& content.StartsWith("이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다.", StringComparison.Ordinal);
}
private AppStateService.AgentRunState? GetAgentRunStateById(string? runId)
=> _appState.GetAgentRunById(runId);
private AppStateService.AgentRunState? GetLatestBranchContextRun()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetLatestConversationRun(conv?.AgentRunHistory);
}
private List<string> GetBranchContextFilePaths(string? runId, int take = 3)
{
return GetExecutionEventFilePaths(runId, take);
}
private List<string> GetExecutionEventFilePaths(string? runId, int take = 2)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetRunDetailSummary(conv?.ExecutionEvents, runId, fileTake: take).FilePaths;
}
private AppStateService.RunPlanHistoryState GetRunPlanHistory(string? runId)
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return _appState.GetRunPlanHistory(conv?.ExecutionEvents, runId);
}
private Border? BuildRunPlanHistoryCard(AppStateService.RunPlanHistoryState history)
{
if (!history.HasAny)
return null;
var panel = new StackPanel();
panel.Children.Add(new TextBlock
{
Text = "계획 히스토리",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1E3A8A"),
Margin = new Thickness(0, 0, 0, 6),
});
panel.Children.Add(BuildPlanHistorySection("원안", history.OriginalSummary, history.OriginalSteps));
panel.Children.Add(BuildPlanHistorySection("수정안", history.RevisedSummary, history.RevisedSteps));
panel.Children.Add(BuildPlanHistorySection("최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps));
var diffSection = BuildPlanDiffSection(history.OriginalSteps, history.FinalApprovedSteps);
if (diffSection != null)
panel.Children.Add(diffSection);
return new Border
{
Background = BrushFromHex("#EFF6FF"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(0, 8, 0, 0),
Child = panel,
};
}
private static Border BuildPlanHistorySection(string title, string? summary, IReadOnlyList<string> steps)
{
var section = new StackPanel();
section.Children.Add(new TextBlock
{
Text = title,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1D4ED8"),
});
var hasContent = false;
if (!string.IsNullOrWhiteSpace(summary))
{
section.Children.Add(new TextBlock
{
Text = summary,
FontSize = 10,
Foreground = Brushes.DimGray,
Margin = new Thickness(0, 2, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
hasContent = true;
}
if (steps.Count > 0)
{
foreach (var step in steps.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"• {step}",
FontSize = 10,
Foreground = BrushFromHex("#334155"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
if (steps.Count > 4)
{
section.Children.Add(new TextBlock
{
Text = $"• ... 외 {steps.Count - 4}개 단계",
FontSize = 10,
Foreground = BrushFromHex("#64748B"),
Margin = new Thickness(0, 1, 0, 0),
});
}
hasContent = true;
}
if (!hasContent)
{
section.Children.Add(new TextBlock
{
Text = "기록 없음",
FontSize = 10,
Foreground = BrushFromHex("#64748B"),
Margin = new Thickness(0, 2, 0, 0),
});
}
return new Border
{
Background = BrushFromHex("#F8FAFC"),
BorderBrush = BrushFromHex("#DBEAFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(6, 5, 6, 5),
Margin = new Thickness(0, 0, 0, 4),
Child = section,
};
}
private Border? BuildPlanDiffSection(IReadOnlyList<string> originalSteps, IReadOnlyList<string> finalSteps)
{
if (originalSteps.Count == 0 || finalSteps.Count == 0)
return null;
var originalNormalized = originalSteps
.Where(step => !string.IsNullOrWhiteSpace(step))
.Select(step => step.Trim())
.ToList();
var finalNormalized = finalSteps
.Where(step => !string.IsNullOrWhiteSpace(step))
.Select(step => step.Trim())
.ToList();
var originalTokens = BuildPlanStepTokens(originalNormalized);
var finalTokens = BuildPlanStepTokens(finalNormalized);
var originalTokenMap = originalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal);
var finalTokenMap = finalTokens.ToDictionary(token => token.Id, StringComparer.Ordinal);
var added = finalTokens
.Where(token => !originalTokenMap.ContainsKey(token.Id))
.Select(token => token.Text)
.ToList();
var removed = originalTokens
.Where(token => !finalTokenMap.ContainsKey(token.Id))
.Select(token => token.Text)
.ToList();
var moved = GetMovedPlanSteps(originalTokens, finalTokenMap);
if (added.Count == 0 && removed.Count == 0 && moved.Count == 0)
return null;
var section = new StackPanel();
var header = new Grid();
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
header.Children.Add(new TextBlock
{
Text = "변경 포인트 (원안 ↔ 최종승인안)",
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex("#1E3A8A"),
});
var severity = GetPlanDiffSeverity(added.Count, removed.Count, moved.Count, originalNormalized.Count, finalNormalized.Count);
var severityBadge = new Border
{
Background = BrushFromHex(severity.BgHex),
BorderBrush = BrushFromHex(severity.BorderHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(6, 1, 6, 1),
Child = new TextBlock
{
Text = severity.Label,
FontSize = 9.5,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(severity.FgHex),
}
};
Grid.SetColumn(severityBadge, 1);
header.Children.Add(severityBadge);
section.Children.Add(header);
foreach (var step in added.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"+ {step}",
FontSize = 10,
Foreground = BrushFromHex("#166534"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
foreach (var step in removed.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"- {step}",
FontSize = 10,
Foreground = BrushFromHex("#991B1B"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
foreach (var item in moved.Take(4))
{
section.Children.Add(new TextBlock
{
Text = $"~ {item.Step} ({item.FromIndex + 1} -> {item.ToIndex + 1})",
FontSize = 10,
Foreground = BrushFromHex("#1D4ED8"),
Margin = new Thickness(0, 1, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
var hiddenCount = Math.Max(0, added.Count - 4) + Math.Max(0, removed.Count - 4) + Math.Max(0, moved.Count - 4);
if (hiddenCount > 0)
{
section.Children.Add(new TextBlock
{
Text = $"... 외 {hiddenCount}개 변경",
FontSize = 10,
Foreground = BrushFromHex("#64748B"),
Margin = new Thickness(0, 1, 0, 0),
});
}
return new Border
{
Background = BrushFromHex("#F8FAFC"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(6, 5, 6, 5),
Margin = new Thickness(0, 0, 0, 2),
Child = section,
};
}
private static List<(string Step, int FromIndex, int ToIndex)> GetMovedPlanSteps(
IReadOnlyList<PlanStepToken> originalTokens,
IReadOnlyDictionary<string, PlanStepToken> finalTokenMap)
{
var moved = new List<(string Step, int FromIndex, int ToIndex)>();
foreach (var token in originalTokens)
{
if (!finalTokenMap.TryGetValue(token.Id, out var finalToken))
continue;
if (token.Index == finalToken.Index)
continue;
moved.Add((finalToken.Text, token.Index, finalToken.Index));
}
return moved
.OrderBy(item => item.ToIndex)
.ToList();
}
private static List<PlanStepToken> BuildPlanStepTokens(IReadOnlyList<string> steps)
{
var counters = new Dictionary<string, int>(StringComparer.Ordinal);
var tokens = new List<PlanStepToken>(steps.Count);
for (var i = 0; i < steps.Count; i++)
{
var text = steps[i].Trim();
if (string.IsNullOrWhiteSpace(text))
continue;
var normalizedKey = text.ToUpperInvariant();
counters.TryGetValue(normalizedKey, out var currentCount);
var nextCount = currentCount + 1;
counters[normalizedKey] = nextCount;
tokens.Add(new PlanStepToken($"{normalizedKey}#{nextCount}", text, i));
}
return tokens;
}
private readonly record struct PlanStepToken(string Id, string Text, int Index);
private (string Label, string BgHex, string BorderHex, string FgHex) GetPlanDiffSeverity(
int addedCount,
int removedCount,
int movedCount,
int originalCount,
int finalCount)
{
var totalChanges = addedCount + removedCount + movedCount;
var baseSize = Math.Max(1, Math.Max(originalCount, finalCount));
var ratio = (double)totalChanges / baseSize;
var llm = _settings.Settings.Llm;
var mediumCount = llm.PlanDiffSeverityMediumCount > 0 ? llm.PlanDiffSeverityMediumCount : 2;
var highCount = llm.PlanDiffSeverityHighCount > 0 ? llm.PlanDiffSeverityHighCount : 5;
if (highCount < mediumCount)
highCount = mediumCount;
var mediumRatioPercent = llm.PlanDiffSeverityMediumRatioPercent > 0 ? llm.PlanDiffSeverityMediumRatioPercent : 25;
var highRatioPercent = llm.PlanDiffSeverityHighRatioPercent > 0 ? llm.PlanDiffSeverityHighRatioPercent : 60;
mediumRatioPercent = Math.Clamp(mediumRatioPercent, 1, 100);
highRatioPercent = Math.Clamp(highRatioPercent, mediumRatioPercent, 100);
var mediumRatio = mediumRatioPercent / 100.0;
var highRatio = highRatioPercent / 100.0;
if (totalChanges >= highCount || ratio >= highRatio)
return ("대폭", "#FEF2F2", "#FCA5A5", "#991B1B");
if (totalChanges >= mediumCount || ratio >= mediumRatio)
return ("중간", "#FFF7ED", "#FDBA74", "#9A3412");
return ("경미", "#ECFDF5", "#86EFAC", "#166534");
}
private void OpenRunFilePath(string path)
{
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
return;
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
if (PreviewWindow.IsOpen)
PreviewWindow.RefreshIfOpen(path);
else
TryShowPreview(path);
}
private void OpenRunFileExternal(string path)
{
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
return;
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
catch { }
}
private void RevealRunFileInFolder(string path)
{
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
return;
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); } catch { }
}
private string GetRunPrimaryTool(string runId)
{
return GetExecutionEventsForRun(runId, 20)
.Where(evt => !string.IsNullOrWhiteSpace(evt.ToolName))
.GroupBy(evt => evt.ToolName!, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault() ?? "";
}
private static string GetFollowUpAssetHint(IEnumerable<string> files)
{
var ext = files
.Select(path => System.IO.Path.GetExtension(path)?.ToLowerInvariant())
.FirstOrDefault(e => !string.IsNullOrWhiteSpace(e));
return ext switch
{
".html" or ".htm" => "브라우저에서 렌더링과 레이아웃까지 함께 점검해줘.",
".md" => "문서 흐름과 섹션 구성을 함께 다듬어줘.",
".csv" or ".xlsx" => "데이터 구조와 컬럼 구성이 맞는지도 함께 확인해줘.",
".docx" => "문서 형식과 본문 구성을 같이 점검해줘.",
".cs" or ".ts" or ".js" or ".py" => "코드 영향 범위와 후속 수정 포인트를 같이 점검해줘.",
_ => "결과물과 관련 파일을 먼저 검토한 뒤 이어서 진행해줘.",
};
}
private string BuildFollowUpLeadForRun(AppStateService.AgentRunState run)
{
var primaryTool = GetRunPrimaryTool(run.RunId);
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
var assetHint = GetFollowUpAssetHint(relatedFiles);
return primaryTool switch
{
"file_write" or "file_edit" or "html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create"
=> $"방금 만든 결과물을 검토하고, 필요한 후속 수정이나 마무리 작업을 이어서 진행해줘. {assetHint}",
"code_review" or "review"
=> "방금 검토한 내용을 바탕으로 우선순위 높은 이슈부터 후속 조치를 이어서 진행해줘. 수정이 필요하면 바로 반영 방향까지 포함해줘.",
"grep" or "glob" or "folder_map"
=> "방금 찾은 내용과 구조를 바탕으로 다음 분석 단계를 이어서 진행해줘. 필요하면 관련 파일을 직접 열어 근거를 더 확인해줘.",
"process" or "build_run"
=> "방금 실행한 명령 결과를 바탕으로 오류나 남은 작업을 정리하고, 필요한 다음 조치를 이어서 진행해줘.",
_ => "방금 완료한 작업을 이어서 진행해줘.",
};
}
private string BuildBranchHintFromRun(AppStateService.AgentRunState run)
{
var primaryTool = GetRunPrimaryTool(run.RunId);
var baseHint = primaryTool switch
{
"file_write" or "file_edit" => "수정안",
"html_create" or "docx_create" or "xlsx_create" or "csv_create" or "md_create" => "결과 확장",
"code_review" or "review" => "리뷰 후속",
"grep" or "glob" or "folder_map" => "탐색 후속",
_ => TruncateForStatus(run.Summary, 16),
};
return string.IsNullOrWhiteSpace(baseHint) ? "후속안" : baseHint;
}
private string BuildFollowUpPromptFromRun(AppStateService.AgentRunState run)
{
var summary = string.IsNullOrWhiteSpace(run.Summary)
? "방금 완료한 작업"
: run.Summary.Trim();
var recentEvents = GetExecutionEventsForRun(run.RunId, 3);
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
var planHistory = GetRunPlanHistory(run.RunId);
var lead = BuildFollowUpLeadForRun(run);
var sb = new System.Text.StringBuilder();
sb.AppendLine(lead);
sb.AppendLine();
sb.AppendLine("[이전 결과 요약]");
sb.AppendLine(summary);
if (relatedFiles.Count > 0)
{
sb.AppendLine();
sb.AppendLine("[관련 파일]");
foreach (var path in relatedFiles)
sb.AppendLine($"- {path}");
}
if (recentEvents.Count > 0)
{
sb.AppendLine();
sb.AppendLine("[최근 실행 로그]");
foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp))
sb.AppendLine($"- {_appState.FormatExecutionEventLine(evt)}");
}
if (planHistory.HasAny)
{
sb.AppendLine();
sb.AppendLine("[계획 히스토리]");
AppendPlanHistoryLines(sb, planHistory, bulletPrefix: "- ");
}
sb.AppendLine();
sb.AppendLine(GetFollowUpAssetHint(relatedFiles));
return sb.ToString().Trim();
}
private string BuildBranchContextMessageFromRun(AppStateService.AgentRunState run)
{
var relatedFiles = GetExecutionEventFilePaths(run.RunId, 3);
var recentEvents = GetExecutionEventsForRun(run.RunId, 3);
var planHistory = GetRunPlanHistory(run.RunId);
var primaryTool = GetRunPrimaryTool(run.RunId);
var sb = new System.Text.StringBuilder();
sb.AppendLine($"이 분기는 방금 완료된 실행을 기준으로 새로 갈라졌습니다. ({BuildBranchHintFromRun(run)})");
sb.AppendLine();
sb.AppendLine($"- 실행 요약: {(string.IsNullOrWhiteSpace(run.Summary) ? " " : run.Summary.Trim())}");
if (!string.IsNullOrWhiteSpace(primaryTool))
sb.AppendLine($"- 주요 도구: {primaryTool}");
if (relatedFiles.Count > 0)
{
sb.AppendLine("- 관련 파일:");
foreach (var path in relatedFiles)
sb.AppendLine($" - {path}");
}
if (recentEvents.Count > 0)
{
sb.AppendLine("- 최근 실행 로그:");
foreach (var evt in recentEvents.OrderBy(evt => evt.Timestamp))
sb.AppendLine($" - {_appState.FormatExecutionEventLine(evt)}");
}
if (planHistory.HasAny)
{
sb.AppendLine("- 계획 히스토리:");
AppendPlanHistoryLines(sb, planHistory, bulletPrefix: " - ");
}
sb.AppendLine();
sb.AppendLine("이 문맥을 바탕으로 여기서 별도의 후속 작업을 이어가면 됩니다.");
return sb.ToString().Trim();
}
private static void AppendPlanHistoryLines(System.Text.StringBuilder sb, AppStateService.RunPlanHistoryState history, string bulletPrefix)
{
AppendPlanHistoryLine(sb, bulletPrefix, "원안", history.OriginalSummary, history.OriginalSteps);
AppendPlanHistoryLine(sb, bulletPrefix, "수정안", history.RevisedSummary, history.RevisedSteps);
AppendPlanHistoryLine(sb, bulletPrefix, "최종승인안", history.FinalApprovedSummary, history.FinalApprovedSteps);
}
private static void AppendPlanHistoryLine(
System.Text.StringBuilder sb,
string bulletPrefix,
string label,
string? summary,
IReadOnlyList<string> steps)
{
var stepPreview = steps
.Where(step => !string.IsNullOrWhiteSpace(step))
.Take(3)
.ToList();
if (string.IsNullOrWhiteSpace(summary) && stepPreview.Count == 0)
{
sb.AppendLine($"{bulletPrefix}{label}: 기록 없음");
return;
}
var summaryPart = string.IsNullOrWhiteSpace(summary) ? "" : summary.Trim();
var stepPart = stepPreview.Count == 0 ? "" : string.Join(" | ", stepPreview);
if (!string.IsNullOrWhiteSpace(summaryPart) && !string.IsNullOrWhiteSpace(stepPart))
sb.AppendLine($"{bulletPrefix}{label}: {summaryPart} ({stepPart})");
else if (!string.IsNullOrWhiteSpace(summaryPart))
sb.AppendLine($"{bulletPrefix}{label}: {summaryPart}");
else
sb.AppendLine($"{bulletPrefix}{label}: {stepPart}");
}
private void EnqueueFollowUpFromRun(AppStateService.AgentRunState run)
{
var prompt = BuildFollowUpPromptFromRun(run);
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
_currentConversation = session.EnqueueDraft(_activeTab, prompt, "next", _storage, "followup") != null
? session.CurrentConversation
: _currentConversation;
}
RefreshDraftQueueUi();
ShowToast("후속 작업이 대기열에 추가되었습니다.");
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void ScrollToRunInTimeline(string runId)
{
if (string.IsNullOrWhiteSpace(runId))
return;
if (_runBannerAnchors.TryGetValue(runId, out var target))
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
Dispatcher.BeginInvoke(() =>
{
target.BringIntoView();
target.Focusable = true;
target.Focus();
}, DispatcherPriority.Background);
return;
}
ShowToast("현재 화면에서 해당 실행 로그를 찾지 못했습니다.", "\uE783");
}
private void BranchConversationFromRun(AppStateService.AgentRunState run)
{
ChatConversation? source;
lock (_convLock) source = _currentConversation;
if (source == null || source.Messages.Count == 0)
return;
var atIndex = source.Messages.FindLastIndex(m => m.Role == "assistant");
if (atIndex < 0)
atIndex = source.Messages.Count - 1;
ForkConversation(
source,
atIndex,
BuildBranchHintFromRun(run),
BuildBranchContextMessageFromRun(run),
run.RunId);
var prompt = BuildFollowUpPromptFromRun(run);
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
_currentConversation = session.EnqueueDraft(_activeTab, prompt, "next", _storage, "followup") != null
? session.CurrentConversation
: _currentConversation;
}
RefreshDraftQueueUi();
ShowToast("새 분기 대화가 생성되고 후속 작업이 대기열에 추가되었습니다.");
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private string? GetLastUserMessageFromConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
return conv?.Messages.LastOrDefault(m => m.Role == "user")?.Content;
}
private void RetryLastUserMessageFromConversation()
{
var lastUserMessage = GetLastUserMessageFromConversation();
if (string.IsNullOrWhiteSpace(lastUserMessage) || InputBox == null)
return;
InputBox.Text = lastUserMessage;
InputBox.CaretIndex = InputBox.Text.Length;
InputBox.Focus();
QueueComposerDraft(priority: "now", explicitKind: "direct", startImmediatelyWhenIdle: true);
}
private void FocusCurrentConversation()
{
if (InputBox == null)
return;
InputBox.Focus();
InputBox.CaretIndex = InputBox.Text?.Length ?? 0;
}
private void ShowRunningConversationsOnly()
{
_runningOnlyFilter = true;
UpdateConversationRunningFilterUi();
PersistConversationListPreferences();
RefreshConversationList();
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void ShowSubAgentTasksOnly()
{
_taskSummaryTaskFilter = "subagent";
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
}
private static string? TryGetDraftIdFromQueueTask(TaskRunStore.TaskRun task)
{
if (!string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(task.Id))
return null;
var parts = task.Id.Split(':');
return parts.Length >= 3 ? parts[^1] : null;
}
private static string? TryGetToolNameFromPermissionTask(TaskRunStore.TaskRun task)
{
if (!string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase) ||
string.IsNullOrWhiteSpace(task.Id))
return null;
var parts = task.Id.Split(':');
return parts.Length >= 2 ? parts[^1] : null;
}
private void RetryQueueTask(TaskRunStore.TaskRun task)
{
var draftId = TryGetDraftIdFromQueueTask(task);
if (string.IsNullOrWhiteSpace(draftId))
return;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.ResetDraftToQueued(_activeTab, draftId, _storage);
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
}
RefreshDraftQueueUi();
StartNextQueuedDraftIfAny(draftId);
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void RemoveQueueTask(TaskRunStore.TaskRun task)
{
var draftId = TryGetDraftIdFromQueueTask(task);
if (string.IsNullOrWhiteSpace(draftId))
return;
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.RemoveDraft(_activeTab, draftId, _storage);
_currentConversation = session.CurrentConversation ?? _currentConversation;
}
}
RefreshDraftQueueUi();
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
}
private void ApplyPermissionOverrideAndRefreshTaskPopup(TaskRunStore.TaskRun task, string? mode)
{
var toolName = TryGetToolNameFromPermissionTask(task);
if (string.IsNullOrWhiteSpace(toolName))
return;
SetToolPermissionOverride(toolName, mode);
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
}
private Border BuildTaskSummaryCard(TaskRunStore.TaskRun task, bool active)
{
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var (kindIcon, kindColor) = GetTaskKindVisual(task.Kind);
var taskStack = new StackPanel();
var headerRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
};
headerRow.Children.Add(new TextBlock
{
Text = kindIcon,
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = kindColor,
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
});
headerRow.Children.Add(new TextBlock
{
Text = active
? $"진행 중 · {task.Title}"
: $"{GetTaskStatusLabel(task.Status)} · {task.Title}",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = active ? primaryText : secondaryText,
TextWrapping = TextWrapping.Wrap,
});
taskStack.Children.Add(headerRow);
if (!string.IsNullOrWhiteSpace(task.Summary))
{
taskStack.Children.Add(new TextBlock
{
Text = task.Summary,
FontSize = 10.5,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
});
}
var reviewChipRow = BuildReviewSignalChipRow(
kind: task.Kind,
toolName: task.Title,
title: task.Title,
summary: task.Summary);
if (reviewChipRow != null)
taskStack.Children.Add(reviewChipRow);
var actionRow = BuildTaskSummaryActionRow(task, active);
if (actionRow != null)
taskStack.Children.Add(actionRow);
return new Border
{
Background = active ? BrushFromHex("#F8FAFC") : Brushes.White,
BorderBrush = BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(8, 0, 8, 6),
Child = taskStack
};
}
private Button CreateTaskSummaryActionButton(
string label,
string bg,
string border,
string fg,
RoutedEventHandler onClick,
bool trailingMargin = true)
{
var button = new Button
{
Content = label,
FontSize = 10.5,
MinHeight = 28,
Padding = new Thickness(9, 4, 9, 4),
Margin = trailingMargin ? new Thickness(0, 0, 6, 0) : new Thickness(0),
Background = BrushFromHex(bg),
BorderBrush = BrushFromHex(border),
BorderThickness = new Thickness(1),
Foreground = BrushFromHex(fg),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Left,
};
button.Click += onClick;
return button;
}
private WrapPanel? BuildTaskSummaryActionRow(TaskRunStore.TaskRun task, bool active)
{
if (string.Equals(task.Kind, "queue", StringComparison.OrdinalIgnoreCase))
{
var actions = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
var primaryButton = CreateTaskSummaryActionButton(
active ? "지금 실행" : "다시 실행",
"#EEF2FF",
"#C7D2FE",
"#3730A3",
(_, _) => RetryQueueTask(task));
actions.Children.Add(primaryButton);
var secondaryButton = CreateTaskSummaryActionButton(
active ? "큐에서 제거" : "정리",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) => RemoveQueueTask(task),
trailingMargin: false);
actions.Children.Add(secondaryButton);
return actions;
}
if (string.Equals(task.Kind, "permission", StringComparison.OrdinalIgnoreCase))
{
var actions = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
Button BuildPermissionButton(string label, string bg, string border, string fg, string? mode, bool margin = true)
{
return CreateTaskSummaryActionButton(
label, bg, border, fg,
(_, _) => ApplyPermissionOverrideAndRefreshTaskPopup(task, mode),
trailingMargin: margin);
}
actions.Children.Add(BuildPermissionButton("권한 요청", "#EFF6FF", "#BFDBFE", "#1D4ED8", PermissionModeCatalog.Default));
actions.Children.Add(BuildPermissionButton("편집 자동 승인", "#ECFDF5", "#BBF7D0", "#166534", PermissionModeCatalog.AcceptEdits));
actions.Children.Add(BuildPermissionButton("계획 모드", "#EEF2FF", "#C7D2FE", "#3730A3", PermissionModeCatalog.Plan));
actions.Children.Add(BuildPermissionButton("권한 건너뛰기", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.BypassPermissions));
actions.Children.Add(BuildPermissionButton("해제", "#F3F4F6", "#D1D5DB", "#374151", null, margin: false));
return actions;
}
return null;
}
private Border BuildHookSummaryCard(AppStateService.HookEventState hook)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var hookCardStack = new StackPanel();
hookCardStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
Children =
{
new TextBlock
{
Text = "\uE756",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = "훅 이벤트",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
}
}
});
hookCardStack.Children.Add(new TextBlock
{
Text = _appState.FormatHookEventLine(hook),
FontSize = 10.5,
TextWrapping = TextWrapping.Wrap,
Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"),
});
var hookActionRow = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
var hookFilterButton = CreateTaskSummaryActionButton(
"훅만 보기",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) =>
{
_taskSummaryTaskFilter = "hook";
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
});
hookActionRow.Children.Add(hookFilterButton);
if (!string.IsNullOrWhiteSpace(hook.RunId))
{
var capturedRunId = hook.RunId;
var timelineButton = CreateTaskSummaryActionButton(
"관련 로그로 이동",
hook.Success ? "#EEF2FF" : "#FEF2F2",
hook.Success ? "#C7D2FE" : "#FCA5A5",
hook.Success ? "#3730A3" : "#991B1B",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
ScrollToRunInTimeline(capturedRunId);
},
trailingMargin: false);
hookActionRow.Children.Add(timelineButton);
}
hookCardStack.Children.Add(hookActionRow);
return new Border
{
Background = hook.Success ? BrushFromHex("#F8FAFC") : BrushFromHex("#FEF2F2"),
BorderBrush = hook.Success ? BrushFromHex("#E2E8F0") : BrushFromHex("#FECACA"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(8, 0, 8, 6),
Child = hookCardStack
};
}
private Border BuildActiveBackgroundSummaryCard(IReadOnlyList<BackgroundJobService.BackgroundJobState> activeBackgroundJobs, int activeBackgroundCount)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var activeBackgroundStack = new StackPanel();
activeBackgroundStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Children =
{
new TextBlock
{
Text = "\uE9F9",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = BrushFromHex("#1D4ED8"),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"실행 중인 백그라운드 작업 {activeBackgroundCount}개",
FontSize = 11,
Foreground = BrushFromHex("#1D4ED8"),
FontWeight = FontWeights.SemiBold,
}
}
});
foreach (var job in activeBackgroundJobs)
{
activeBackgroundStack.Children.Add(new TextBlock
{
Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 52)}",
Margin = new Thickness(0, 4, 0, 0),
FontSize = 10.5,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
});
}
var activeActionRow = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
var runningFilterButton = CreateTaskSummaryActionButton(
"진행 중 대화만 보기",
"#DBEAFE",
"#93C5FD",
"#1D4ED8",
(_, _) => ShowRunningConversationsOnly());
activeActionRow.Children.Add(runningFilterButton);
var subTaskButton = CreateTaskSummaryActionButton(
"서브 작업만 보기",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) => ShowSubAgentTasksOnly(),
trailingMargin: false);
activeActionRow.Children.Add(subTaskButton);
activeBackgroundStack.Children.Add(activeActionRow);
return new Border
{
Background = BrushFromHex("#EFF6FF"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(8, 0, 8, 8),
Child = activeBackgroundStack
};
}
private Border BuildRecentBackgroundJobCard(BackgroundJobService.BackgroundJobState job)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var jobCardStack = new StackPanel();
var isFailed = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase);
jobCardStack.Children.Add(new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 0, 0, 2),
Children =
{
new TextBlock
{
Text = "\uE823",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
Margin = new Thickness(0, 0, 6, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
}
}
});
jobCardStack.Children.Add(new TextBlock
{
Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 72)}",
FontSize = 10.5,
TextWrapping = TextWrapping.Wrap,
Foreground = isFailed
? BrushFromHex("#991B1B")
: secondaryText,
});
var jobActionRow = new WrapPanel
{
Margin = new Thickness(0, 8, 0, 0),
};
var focusButton = CreateTaskSummaryActionButton(
"이 대화로 이동",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
FocusCurrentConversation();
});
jobActionRow.Children.Add(focusButton);
var subagentOnlyButton = CreateTaskSummaryActionButton(
"서브 작업만 보기",
"#F8FAFC",
"#CBD5E1",
"#334155",
(_, _) => ShowSubAgentTasksOnly());
jobActionRow.Children.Add(subagentOnlyButton);
if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
{
var retryBackgroundButton = CreateTaskSummaryActionButton(
"이 작업 다시 시도",
"#FEF2F2",
"#FCA5A5",
"#991B1B",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
RetryLastUserMessageFromConversation();
},
trailingMargin: false);
jobActionRow.Children.Add(retryBackgroundButton);
}
jobCardStack.Children.Add(jobActionRow);
return new Border
{
Background = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase)
? BrushFromHex("#FEF2F2")
: BrushFromHex("#F8FAFC"),
BorderBrush = string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase)
? BrushFromHex("#FECACA")
: BrushFromHex("#E2E8F0"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(8, 0, 8, 6),
Child = jobCardStack
};
}
private static (Brush Background, Brush Border, Brush Foreground) GetPermissionModePalette(string? mode)
{
var normalized = PermissionModeCatalog.NormalizeGlobalMode(mode);
return normalized switch
{
var x when string.Equals(x, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#ECFDF5"), BrushFromHex("#86EFAC"), BrushFromHex("#166534")),
var x when string.Equals(x, PermissionModeCatalog.AcceptEdits, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
var x when string.Equals(x, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
var x when string.Equals(x, PermissionModeCatalog.BypassPermissions, StringComparison.OrdinalIgnoreCase)
=> (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C")),
_ => (BrushFromHex("#F8FAFC"), BrushFromHex("#CBD5E1"), BrushFromHex("#334155")),
};
}
private static (string Icon, Brush Color) GetTaskKindVisual(string? kind)
{
var normalized = kind?.Trim().ToLowerInvariant() ?? "";
return normalized switch
{
"permission" => ("\uE72E", BrushFromHex("#1D4ED8")),
"queue" => ("\uE14C", BrushFromHex("#7C3AED")),
"tool" => ("\uE90F", BrushFromHex("#A16207")),
"hook" => ("\uE756", BrushFromHex("#0F766E")),
"subagent" => ("\uE902", BrushFromHex("#2563EB")),
_ => ("\uE946", BrushFromHex("#475569")),
};
}
private static (string Label, Brush Background, Brush Border, Brush Foreground) GetPermissionEventStatusDisplay(string? status)
{
var normalized = status?.Trim().ToLowerInvariant() ?? "";
return normalized switch
{
"denied" => ("차단", BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
"granted" => ("허용", BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
"approved" => ("승인", BrushFromHex("#EEF2FF"), BrushFromHex("#C7D2FE"), BrushFromHex("#3730A3")),
"auto" => ("자동 허용", BrushFromHex("#FFF7ED"), BrushFromHex("#FED7AA"), BrushFromHex("#9A3412")),
_ => ("확인 대기", BrushFromHex("#F8FAFC"), BrushFromHex("#E2E8F0"), BrushFromHex("#475569")),
};
}
private void AddTaskSummaryPermissionSection(StackPanel panel, ChatConversation? currentConversation)
{
var permissionSummary = _appState.GetPermissionSummary(currentConversation);
var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(permissionSummary.EffectiveMode);
var (bg, border, fg) = GetPermissionModePalette(normalizedMode);
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var content = new StackPanel();
content.Children.Add(new TextBlock
{
Text = $"현재 권한 · {PermissionModeCatalog.ToDisplayLabel(normalizedMode)}",
FontWeight = FontWeights.SemiBold,
Foreground = fg,
});
content.Children.Add(new TextBlock
{
Text = permissionSummary.Description,
Margin = new Thickness(0, 3, 0, 0),
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
});
content.Children.Add(new TextBlock
{
Text = $"기본 {PermissionModeCatalog.ToDisplayLabel(permissionSummary.DefaultMode)} · 예외 {permissionSummary.OverrideCount}개",
Margin = new Thickness(0, 2, 0, 0),
Foreground = primaryText,
});
panel.Children.Add(new Border
{
Background = bg,
BorderBrush = border,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(8, 0, 8, 8),
Child = content,
});
}
private void AddTaskSummaryPermissionHistorySection(StackPanel panel)
{
var recentPermissions = _appState.GetRecentPermissionEvents(4);
if (recentPermissions.Count == 0)
return;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
panel.Children.Add(new TextBlock
{
Text = "최근 권한 이력",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(10, 0, 10, 4),
});
foreach (var permission in recentPermissions)
{
var (statusLabel, statusBg, statusBorder, statusFg) = GetPermissionEventStatusDisplay(permission.Status);
var card = new StackPanel();
card.Children.Add(new TextBlock
{
Text = $"{permission.Timestamp:HH:mm:ss} · {permission.ToolName}",
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
FontSize = 10.5,
});
card.Children.Add(new TextBlock
{
Text = statusLabel,
Margin = new Thickness(0, 3, 0, 0),
Foreground = statusFg,
FontWeight = FontWeights.SemiBold,
FontSize = 10.5,
});
if (!string.IsNullOrWhiteSpace(permission.Summary))
{
card.Children.Add(new TextBlock
{
Text = TruncateForStatus(permission.Summary, 80),
Margin = new Thickness(0, 2, 0, 0),
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
FontSize = 10,
});
}
panel.Children.Add(new Border
{
Background = statusBg,
BorderBrush = statusBorder,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(8, 0, 8, 6),
Child = card,
});
}
}
private void AddTaskSummaryHookSection(StackPanel panel)
{
var recentHooks = _appState.GetRecentHookEvents(5);
if (recentHooks.Count == 0)
return;
panel.Children.Add(new TextBlock
{
Text = "최근 훅 이력",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.DimGray,
Margin = new Thickness(10, 0, 10, 4),
});
foreach (var hook in recentHooks)
panel.Children.Add(BuildHookSummaryCard(hook));
}
private void AddTaskSummaryBackgroundSection(StackPanel panel)
{
var activeBackgroundJobs = _appState.GetActiveBackgroundJobs(3);
var recentBackgroundJobs = _appState.GetRecentBackgroundJobs(4);
var activeBackgroundCount = _appState.GetBackgroundJobSummary().ActiveCount;
if (activeBackgroundCount > 0)
panel.Children.Add(BuildActiveBackgroundSummaryCard(activeBackgroundJobs, activeBackgroundCount));
if (recentBackgroundJobs.Count == 0)
return;
panel.Children.Add(new TextBlock
{
Text = "최근 백그라운드 작업",
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.DimGray,
Margin = new Thickness(10, 0, 10, 4),
});
foreach (var job in recentBackgroundJobs)
panel.Children.Add(BuildRecentBackgroundJobCard(job));
}
private void AddTaskSummaryObservabilitySections(StackPanel panel, ChatConversation? currentConversation)
{
AddTaskSummaryPermissionSection(panel, currentConversation);
AddTaskSummaryPermissionHistorySection(panel);
AddTaskSummaryHookSection(panel);
AddTaskSummaryBackgroundSection(panel);
}
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
{
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!;
return new System.Windows.Media.SolidColorBrush(c);
}
}