Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.xaml.cs

15129 lines
628 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 bool IsOverlayEncryptionEnabled => _settings.Settings.Llm.EncryptionEnabled;
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 AxAgentExecutionEngine _chatEngine;
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 double _sidebarExpandedWidth = 262;
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 readonly Dictionary<string, bool> _overlaySectionExpandedStates = new(StringComparer.OrdinalIgnoreCase);
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 Border? _userAskCard; // transcript 내 질문 카드
private string? _pendingPlanSummary;
private List<string> _pendingPlanSteps = new();
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 readonly DispatcherTimer _elapsedTimer;
private DateTime _streamStartTime;
private TextBlock? _elapsedLabel;
// 타이핑 효과
private readonly DispatcherTimer _typingTimer;
private readonly DispatcherTimer _gitRefreshTimer;
private readonly DispatcherTimer _conversationSearchTimer;
private readonly DispatcherTimer _inputUiRefreshTimer;
private readonly DispatcherTimer _executionHistoryRenderTimer;
private readonly DispatcherTimer _taskSummaryRefreshTimer;
private readonly DispatcherTimer _conversationPersistTimer;
private readonly DispatcherTimer _agentUiEventTimer;
private readonly DispatcherTimer _tokenUsagePopupCloseTimer;
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 string _lastCompactionStageSummary = "";
private int _sessionCompactionCount;
private int _sessionAutomaticCompactionCount;
private int _sessionManualCompactionCount;
private int _sessionCompactionSavedTokens;
private int _sessionMemoryCompactionCount;
private int _sessionMicrocompactBoundaryCount;
private int _sessionSnipCompactionCount;
private bool _pendingPostCompaction;
private int _sessionPostCompactionResponseCount;
private int _sessionPostCompactionPromptTokens;
private int _sessionPostCompactionCompletionTokens;
private bool _pendingExecutionHistoryAutoScroll;
private readonly Dictionary<string, ChatConversation> _pendingConversationPersists = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _expandedDraftQueueTabs = new(StringComparer.OrdinalIgnoreCase);
private AgentEvent? _pendingAgentUiEvent;
private double _lastResponsiveComposerWidth;
private double _lastResponsiveMessageWidth;
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();
_chatEngine = new AxAgentExecutionEngine();
_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 = ShowInlineUserAskAsync,
};
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
_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();
};
_conversationSearchTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
_conversationSearchTimer.Tick += (_, _) =>
{
_conversationSearchTimer.Stop();
RefreshConversationList();
};
_inputUiRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) };
_inputUiRefreshTimer.Tick += (_, _) =>
{
_inputUiRefreshTimer.Stop();
RefreshContextUsageVisual();
RefreshDraftQueueUi();
};
_executionHistoryRenderTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
_executionHistoryRenderTimer.Tick += (_, _) =>
{
_executionHistoryRenderTimer.Stop();
RenderMessages(preserveViewport: true);
if (_pendingExecutionHistoryAutoScroll)
AutoScrollIfNeeded();
_pendingExecutionHistoryAutoScroll = false;
};
_taskSummaryRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(120) };
_taskSummaryRefreshTimer.Tick += (_, _) =>
{
_taskSummaryRefreshTimer.Stop();
UpdateTaskSummaryIndicators();
};
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
_conversationPersistTimer.Tick += (_, _) =>
{
_conversationPersistTimer.Stop();
FlushPendingConversationPersists();
};
_agentUiEventTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(90) };
_agentUiEventTimer.Tick += (_, _) =>
{
_agentUiEventTimer.Stop();
FlushPendingAgentUiEvent();
};
_tokenUsagePopupCloseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(140) };
_tokenUsagePopupCloseTimer.Tick += (_, _) =>
{
_tokenUsagePopupCloseTimer.Stop();
CloseTokenUsagePopupIfIdle();
};
KeyDown += ChatWindow_KeyDown;
MouseMove += (_, _) =>
{
if (TokenUsagePopup?.IsOpen == true)
CloseTokenUsagePopupIfIdle();
};
PreviewMouseDown += (_, _) =>
{
if (TokenUsagePopup != null)
TokenUsagePopup.IsOpen = false;
};
Deactivated += (_, _) =>
{
if (TokenUsagePopup != null)
TokenUsagePopup.IsOpen = false;
};
UpdateConversationFailureFilterUi();
UpdateConversationSortUi();
UpdateConversationRunningFilterUi();
Loaded += (_, _) =>
{
ApplyAgentThemeResources();
// ── 즉시 필요한 UI 초기화만 동기 실행 ──
SetupUserInfo();
_selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern";
_folderDataUsage = GetAutomaticFolderDataUsage();
UpdateAnalyzerButtonVisibility();
UpdateModelLabel();
RefreshInlineSettingsPanel();
ApplyExpressionLevelUi();
UpdateSidebarModeMenu();
RefreshContextUsageVisual();
UpdateTopicPresetScrollMode();
UpdateResponsiveChatLayout();
UpdateInputBoxHeight();
InputBox.Focus();
MessageScroll.ScrollChanged += MessageScroll_ScrollChanged;
// ── 무거운 작업은 유휴 시점에 비동기 실행 ──
Dispatcher.BeginInvoke(() =>
{
TemplateService.LoadCustomMoods(_settings.Settings.Llm.CustomMoods);
BuildTopicButtons();
RestoreLastConversations();
RefreshConversationList();
UpdateResponsiveChatLayout();
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);
};
SizeChanged += (_, _) =>
{
UpdateTopicPresetScrollMode();
if (UpdateResponsiveChatLayout())
RenderMessages(preserveViewport: true);
};
Closed += (_, _) =>
{
_settings.SettingsChanged -= Settings_SettingsChanged;
SubAgentTool.StatusChanged -= OnSubAgentStatusChanged;
_streamCts?.Cancel();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_conversationSearchTimer.Stop();
_inputUiRefreshTimer.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" => "모든 작업 유형",
"Code" => "모든 프로젝트",
_ => "모든 주제",
};
stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText,
string.IsNullOrEmpty(_selectedCategory),
() => { _selectedCategory = ""; UpdateCategoryLabel(); RefreshConversationList(); }));
stack.Children.Add(CreateSep());
if (_activeTab == "Cowork")
{
// 코워크: 작업 유형 기반 필터
var presets = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets);
var categoryItems = presets
.Select(p => (Category: p.Category, Label: p.Label, Symbol: p.Symbol, Color: p.Color))
.Concat(_storage.LoadAllMeta()
.Where(c => string.Equals(NormalizeTabName(c.Tab), "Cowork", StringComparison.OrdinalIgnoreCase))
.Select(c => (Category: c.Category?.Trim() ?? "", Label: c.Category?.Trim() ?? "", Symbol: "\uE8BD", Color: "#6B7280")))
.Where(item => !string.IsNullOrWhiteSpace(item.Category))
.DistinctBy(item => item.Category, StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var categoryItem in categoryItems)
{
var capturedCategory = categoryItem.Category;
var brush = BrushFromHex(categoryItem.Color);
stack.Children.Add(CreateCatItem(categoryItem.Symbol, categoryItem.Label, brush,
string.Equals(_selectedCategory, capturedCategory, StringComparison.OrdinalIgnoreCase),
() => { _selectedCategory = capturedCategory; UpdateCategoryLabel(); RefreshConversationList(); }));
}
}
else if (_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" => "모든 작업 유형",
"Code" => "모든 프로젝트",
_ => "주제 선택",
};
CategoryIcon.Text = "\uE8BD";
}
else if (_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 (_activeTab == "Cowork")
{
var preset = Services.PresetService.GetByTabWithCustom("Cowork", _settings.Settings.Llm.CustomPresets)
.FirstOrDefault(p => string.Equals(p.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase));
CategoryLabel.Text = preset?.Label ?? _selectedCategory;
CategoryIcon.Text = preset?.Symbol ?? "\uE8BD";
}
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;
// 좌측 필터는 상단 공통 드롭다운 하나만 유지한다.
// 탭별 중복 메뉴(주제/작업 유형/워크스페이스)는 항상 숨긴다.
if (SidebarChatMenu.Visibility != Visibility.Collapsed) SidebarChatMenu.Visibility = Visibility.Collapsed;
if (SidebarCoworkMenu.Visibility != Visibility.Collapsed) SidebarCoworkMenu.Visibility = Visibility.Collapsed;
if (SidebarCodeMenu.Visibility != Visibility.Collapsed) SidebarCodeMenu.Visibility = Visibility.Collapsed;
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",
"nord" => "Nord",
"ember" => "Ember",
"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" => 120,
"simple" => 96,
_ => 108,
};
UpdateInputBoxHeight();
}
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)
{
RenderFolderMenuItems(null);
}
private void ShowFolderMenu()
{
RenderFolderMenuItems(null);
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 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)
.Take(maxDisplay * 2)
.ToList();
var filteredRecent = recentFolders
.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 = folder;
var itemBorder = CreatePopupMenuRow(
"\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)
{
AddWorkspaceRows(filteredRecent);
}
var remainingFolders = workspaceFolders
.Where(path => !filteredRecent.Contains(path, StringComparer.OrdinalIgnoreCase))
.ToList();
if (remainingFolders.Count > 0)
{
FolderMenuItems.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(6, 6, 6, 10),
Opacity = 0.35,
});
AddWorkspaceRows(remainingFolders);
}
if (filteredRecent.Count == 0 && remainingFolders.Count == 0)
{
FolderMenuItems.Children.Add(new TextBlock
{
Text = "최근 작업 폴더가 없습니다.",
FontSize = 12,
Foreground = secondaryText,
Margin = new Thickness(10, 8, 10, 12),
});
}
FolderMenuItems.Children.Add(new Border
{
Height = 1,
Background = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
Margin = new Thickness(6, 8, 6, 10),
Opacity = 0.35,
});
var browseBorder = CreatePopupMenuRow(
"\uE710",
"다른 폴더 선택",
string.Empty,
false,
accentBrush,
secondaryText,
primaryText,
() =>
{
FolderMenuPopup.IsOpen = false;
BrowseWorkFolder();
});
browseBorder.Margin = new Thickness(0);
browseBorder.MouseLeftButtonUp += (_, _) =>
{
FolderMenuPopup.IsOpen = false;
};
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 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 = "";
}
}
}
/// <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 = GetAutomaticFolderDataUsage();
_selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern";
}
private void LoadCompactionMetricsFromConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_sessionCompactionCount = conv?.CompactionCount ?? 0;
_sessionAutomaticCompactionCount = conv?.AutomaticCompactionCount ?? 0;
_sessionManualCompactionCount = conv?.ManualCompactionCount ?? 0;
_sessionCompactionSavedTokens = conv?.CompactionSavedTokens ?? 0;
_sessionMemoryCompactionCount = conv?.SessionMemoryCompactionCount ?? 0;
_sessionMicrocompactBoundaryCount = conv?.MicrocompactBoundaryCount ?? 0;
_sessionSnipCompactionCount = conv?.SnipCompactionCount ?? 0;
_pendingPostCompaction = conv?.PendingPostCompaction ?? false;
_sessionPostCompactionResponseCount = conv?.PostCompactionResponseCount ?? 0;
_sessionPostCompactionPromptTokens = conv?.PostCompactionPromptTokens ?? 0;
_sessionPostCompactionCompletionTokens = conv?.PostCompactionCompletionTokens ?? 0;
_lastCompactionAt = conv?.LastCompactionAt;
_lastCompactionWasAutomatic = conv?.LastCompactionWasAutomatic ?? false;
_lastCompactionBeforeTokens = conv?.LastCompactionBeforeTokens;
_lastCompactionAfterTokens = conv?.LastCompactionAfterTokens;
_lastCompactionStageSummary = conv?.LastCompactionStageSummary ?? "";
}
/// <summary>현재 하단 바 설정을 대화에 저장합니다.</summary>
private void SaveConversationSettings()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null) return;
try
{
var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
var dataUsage = GetAutomaticFolderDataUsage();
var mood = _selectedMood ?? (_settings.Settings.Llm.DefaultMood ?? "modern");
var outputFormat = conv.OutputFormat ?? "auto";
var unchanged =
string.Equals(conv.Permission ?? "", normalizedPermission, StringComparison.OrdinalIgnoreCase) &&
string.Equals(conv.DataUsage ?? "", dataUsage, StringComparison.OrdinalIgnoreCase) &&
string.Equals(conv.Mood ?? "", mood, StringComparison.OrdinalIgnoreCase) &&
string.Equals(conv.OutputFormat ?? "auto", outputFormat, StringComparison.OrdinalIgnoreCase);
if (unchanged)
return;
conv.Permission = normalizedPermission;
conv.DataUsage = dataUsage;
conv.Mood = mood;
var session = ChatSession;
if (session != null)
{
lock (_convLock)
_currentConversation = session.SaveConversationSettings(_activeTab, normalizedPermission, dataUsage, outputFormat, mood, _storage);
}
else
{
_storage.Save(conv);
}
}
catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
}
// ─── 권한 메뉴 ─────────────────────────────────────────────────────────
private string _lastPermissionBannerMode = "";
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)
{
_folderDataUsage = GetAutomaticFolderDataUsage();
}
private string GetAutomaticFolderDataUsage()
=> _activeTab switch
{
"Code" => "active",
"Cowork" => "passive",
_ => "none",
};
/// <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;
SidebarResizeSplitter.Visibility = Visibility.Visible;
var targetWidth = Math.Max(190, _sidebarExpandedWidth);
if (animated)
{
AnimateSidebar(0, targetWidth, () => SidebarColumn.MinWidth = 168);
}
else
{
SidebarColumn.MinWidth = 168;
SidebarColumn.Width = new GridLength(targetWidth);
}
return;
}
var currentWidth = SidebarColumn.ActualWidth > 0 ? SidebarColumn.ActualWidth : SidebarColumn.Width.Value;
if (currentWidth > 0)
_sidebarExpandedWidth = Math.Max(190, currentWidth);
SidebarColumn.MinWidth = 0;
if (animated)
{
AnimateSidebar(currentWidth > 0 ? currentWidth : _sidebarExpandedWidth, 0, () =>
{
SidebarPanel.Visibility = Visibility.Collapsed;
SidebarResizeSplitter.Visibility = Visibility.Collapsed;
IconBarColumn.Width = new GridLength(52);
IconBarPanel.Visibility = Visibility.Visible;
});
}
else
{
SidebarColumn.Width = new GridLength(0);
SidebarPanel.Visibility = Visibility.Collapsed;
SidebarResizeSplitter.Visibility = Visibility.Collapsed;
IconBarColumn.Width = new GridLength(52);
IconBarPanel.Visibility = Visibility.Visible;
}
}
private void SidebarResizeSplitter_DragCompleted(object sender, DragCompletedEventArgs e)
{
if (!_sidebarVisible)
return;
var currentWidth = SidebarColumn.ActualWidth > 0 ? SidebarColumn.ActualWidth : SidebarColumn.Width.Value;
_sidebarExpandedWidth = Math.Clamp(currentWidth, 190, 420);
SidebarColumn.Width = new GridLength(_sidebarExpandedWidth);
}
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();
}
// ─── 대화 목록 ────────────────────────────────────────────────────────
// ─── 검색 ────────────────────────────────────────────────────────────
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)
{
_conversationSearchTimer.Stop();
_conversationSearchTimer.Start();
}
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()
{
ChatConversation? conversation;
lock (_convLock)
{
conversation = _currentConversation;
ChatTitle.Text = conversation?.Title ?? "";
}
UpdateSelectedPresetGuide(conversation);
}
// ─── 메시지 렌더링 ───────────────────────────────────────────────────
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 = GetVisibleTimelineMessages(conv);
var visibleEvents = GetVisibleTimelineEvents(conv);
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 orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
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);
}
// ─── 커스텀 체크 아이콘 (모든 팝업 메뉴 공통) ─────────────────────────
/// <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);
}
}
// ─── 스트리밍 커서 깜빡임 + 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}초";
StatusElapsed.Visibility = Visibility.Visible;
}
}
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.CreateFreshConversation(_activeTab, _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();
_attachedFiles.Clear();
RefreshAttachedFilesUI();
LoadConversationSettings();
LoadCompactionMetricsFromConversation();
SyncAppStateWithCurrentConversation();
UpdateChatTitle();
UpdateFolderBar();
UpdateSelectedPresetGuide();
UpdateConditionalSkillActivation(reset: true);
RenderMessages();
RefreshConversationList();
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();
UpdateInputBoxHeight();
ScheduleInputUiRefresh();
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;
ScheduleInputUiRefresh();
return;
}
}
SlashPopup.IsOpen = false;
ScheduleInputUiRefresh();
}
private static void SyncLatestAssistantMessage(ChatConversation conv, string content)
{
if (conv.Messages.Count == 0)
return;
for (var i = conv.Messages.Count - 1; i >= 0; i--)
{
var message = conv.Messages[i];
if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase))
continue;
message.Content = content;
return;
}
}
private void ScheduleInputUiRefresh()
{
if (_inputUiRefreshTimer == null)
return;
_inputUiRefreshTimer.Stop();
_inputUiRefreshTimer.Start();
}
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;
if (runTab is "Cowork" or "Code")
conv.ShowExecutionHistory = false;
}
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();
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
RenderMessages(preserveViewport: true);
ForceScrollToEnd();
var llm = _settings.Settings.Llm;
var working = conv.Messages.ToList();
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
working,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
true,
CancellationToken.None);
var beforeTokens = compactResult.BeforeTokens;
var afterTokens = compactResult.AfterTokens;
RecordCompactionStats(compactResult, wasAutomatic: false);
if (compactResult.Changed)
{
lock (_convLock)
{
conv.Messages = working;
}
}
var assistantText = compactResult.Changed
? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}\n- 단계: {compactResult.StageSummary}\n- 절감량: {compactResult.SavedTokens:N0} tokens"
: "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다.";
lock (_convLock)
{
var session = ChatSession;
_chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
_currentConversation = session?.CurrentConversation ?? conv;
conv = _currentConversation!;
}
SaveLastConversations();
_storage.Save(conv);
RenderMessages(preserveViewport: true);
ForceScrollToEnd();
if (StatusTokens != null)
StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}";
SetStatus(compactResult.Changed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", 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 };
lock (_convLock)
{
var session = ChatSession;
if (session != null)
{
session.AppendMessage(runTab, userMsg, useForTitle: true);
_chatEngine.CommitAssistantMessage(session, conv, runTab, assistantText, storage: _storage);
_currentConversation = session.CurrentConversation;
conv = _currentConversation!;
}
else
{
conv.Messages.Add(userMsg);
_chatEngine.CommitAssistantMessage(null, conv, runTab, assistantText, storage: _storage);
}
}
SaveLastConversations();
_storage.Save(conv);
ChatSession?.RememberConversation(runTab, conv.Id);
UpdateChatTitle();
InputBox.Text = "";
EmptyState.Visibility = Visibility.Collapsed;
RenderMessages(preserveViewport: true);
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(string? directText = null)
{
var useComposerInput = directText == null;
var rawText = (directText ?? InputBox.Text).Trim();
// 슬래시 칩이 활성화된 경우 명령어 앞에 붙임
var text = useComposerInput && _slashPalette.ActiveCommand != null
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
: rawText;
if (useComposerInput)
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;
PersistConversationSnapshot(originTab, conv, "대화 중간 저장 실패");
}
TryPersistConversation(force: true);
UpdateChatTitle();
if (useComposerInput)
{
InputBox.Text = "";
UpdateInputBoxHeight();
}
EmptyState.Visibility = Visibility.Collapsed;
RenderMessages(preserveViewport: true);
// 대화 통계 기록
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 assistantContent = string.Empty;
_activeStreamText = null;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus("응답 생성 중...", spinning: true);
// ── 자동 모델 라우팅 (try 외부 선언 — finally에서 정리) ──
ModelRouteResult? routeResult = null;
try
{
List<ChatMessage> sendMessages;
// 첨부 파일 컨텍스트 삽입
string? fileContext = null;
if (_attachedFiles.Count > 0)
{
fileContext = BuildFileContextPrompt();
if (!string.IsNullOrEmpty(fileContext))
{
// 전송용 메시지에는 엔진이 적용하고, 원본 대화에는 첨부 기록만 남김
}
userMsg.AttachedFiles = _attachedFiles.ToList();
_attachedFiles.Clear();
RefreshAttachedFilesUI();
}
// ── 이미지 첨부 ──
List<ImageAttachment>? outboundImages = null;
if (_pendingImages.Count > 0)
{
userMsg.Images = _pendingImages.ToList();
outboundImages = _pendingImages.ToList();
_pendingImages.Clear();
AttachedFilesPanel.Items.Clear();
if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed;
}
var preparedExecution = PrepareExecutionForConversation(
conv,
runTab,
slashSystem,
fileContext,
outboundImages);
var executionMode = preparedExecution.Mode;
sendMessages = preparedExecution.Messages;
// ── 전송 전 컨텍스트 사전 압축 ──
{
var llm = _settings.Settings.Llm;
var compactResult = await ContextCondenser.CondenseWithStatsAsync(
sendMessages,
_llm,
llm.MaxContextTokens,
llm.EnableProactiveContextCompact,
llm.ContextCompactTriggerPercent,
false,
_streamCts!.Token);
if (compactResult.Changed)
{
RecordCompactionStats(compactResult, wasAutomatic: true);
SetStatus($"컨텍스트를 사전 정리했습니다 · {compactResult.BeforeTokens:N0} → {compactResult.AfterTokens:N0} tokens", 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);
}
}
var executionOutcome = await ExecutePreparedTurnAsync(
conv,
runTab,
originTab,
preparedExecution,
"응답 생성 중...");
conv = executionOutcome.Conversation;
assistantContent = executionOutcome.AssistantContent;
draftSucceeded = executionOutcome.DraftSucceeded;
draftCancelled = executionOutcome.DraftCancelled;
draftFailure = executionOutcome.DraftFailure;
}
finally
{
// 자동 라우팅 오버라이드 해제
if (routeResult != null)
{
_llm.ClearRouteOverride();
UpdateModelLabel();
}
}
FinalizeQueuedDraft(originTab, queuedDraftId, draftSucceeded, draftCancelled, draftFailure);
}
private async Task<string> RunAgentLoopAsync(
string runTab,
string originTab,
ChatConversation conversation,
IReadOnlyList<ChatMessage> sendMessages,
CancellationToken cancellationToken)
{
OpenWorkflowAnalyzerIfEnabled();
_agentCumulativeInputTokens = 0;
_agentCumulativeOutputTokens = 0;
_agentLoop.EventOccurred += OnAgentEvent;
_agentLoop.UserDecisionCallback = CreatePlanDecisionCallback();
try
{
_agentLoop.ActiveTab = runTab;
var response = await _agentLoop.RunAsync(sendMessages.ToList(), cancellationToken);
if (_settings.Settings.Llm.NotifyOnComplete)
{
var title = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
? "AX Code Agent"
: "AX Cowork Agent";
var message = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
? "코드 작업이 완료되었습니다."
: "코워크 작업이 완료되었습니다.";
Services.NotificationService.Notify(title, message);
}
return response;
}
finally
{
_agentLoop.EventOccurred -= OnAgentEvent;
_agentLoop.UserDecisionCallback = null;
}
}
private AxAgentExecutionEngine.PreparedExecution PrepareExecutionForConversation(
ChatConversation conversation,
string runTab,
string? slashSystem,
string? fileContext = null,
IReadOnlyList<ImageAttachment>? images = null)
{
var coworkSystem = string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)
? BuildCoworkSystemPrompt()
: null;
var codeSystem = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)
? BuildCodeSystemPrompt()
: null;
var (resolvedService, _) = _llm.GetCurrentModelInfo();
return _chatEngine.PrepareExecution(
conversation,
runTab,
_settings.Settings.Llm.Streaming,
resolvedService,
conversation.SystemCommand,
slashSystem,
coworkSystem,
codeSystem,
fileContext,
images);
}
private void ResetStreamingUiState()
{
FlushPendingConversationPersists();
FlushPendingAgentUiEvent();
_cursorTimer.Stop();
_elapsedTimer.Stop();
_typingTimer.Stop();
_executionHistoryRenderTimer.Stop();
_pendingExecutionHistoryAutoScroll = false;
_taskSummaryRefreshTimer.Stop();
_conversationPersistTimer.Stop();
_agentUiEventTimer.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();
}
private void FinalizeConversationTurn(string rememberTab, ChatConversation conversation)
{
lock (_convLock)
_currentConversation = conversation;
RenderMessages(preserveViewport: true);
AutoScrollIfNeeded();
PersistConversationSnapshot(rememberTab, conversation, "대화 저장 실패");
SyncTabConversationIdsFromSession();
RefreshConversationList();
}
private void FinalizeQueuedDraft(string originTab, string? queuedDraftId, bool draftSucceeded, bool draftCancelled, string? draftFailure)
{
if (string.IsNullOrWhiteSpace(queuedDraftId))
return;
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.Equals(originTab, _activeTab, StringComparison.OrdinalIgnoreCase))
_ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background);
}
private sealed record PreparedTurnOutcome(
ChatConversation Conversation,
string AssistantContent,
bool DraftSucceeded,
bool DraftCancelled,
string? DraftFailure);
private async Task<PreparedTurnOutcome> ExecutePreparedTurnAsync(
ChatConversation conversation,
string runTab,
string rememberTab,
AxAgentExecutionEngine.PreparedExecution preparedExecution,
string busyStatus)
{
_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();
ForceScrollToEnd();
var assistantContent = string.Empty;
var draftSucceeded = false;
var draftCancelled = false;
string? draftFailure = null;
var responseElapsedMs = 0L;
var promptTokens = 0;
var completionTokens = 0;
string? assistantMetaRunId = null;
_activeStreamText = null;
_cachedStreamContent = "";
_displayedLength = 0;
_cursorVisible = true;
_aiIconPulseStopped = false;
_streamStartTime = DateTime.UtcNow;
_elapsedTimer.Start();
SetStatus(busyStatus, spinning: true);
try
{
var response = await _chatEngine.ExecutePreparedAsync(
preparedExecution,
(messages, token) => RunAgentLoopAsync(runTab, rememberTab, conversation, messages, token),
(messages, token) => _llm.SendAsync(messages.ToList(), token),
_streamCts.Token);
assistantContent = response;
responseElapsedMs = Math.Max(0, (long)(DateTime.UtcNow - _streamStartTime).TotalMilliseconds);
assistantMetaRunId = _appState.AgentRun.RunId;
var usage = _llm.LastTokenUsage;
if (usage != null)
{
if (runTab is "Cowork" or "Code" && (_agentCumulativeInputTokens > 0 || _agentCumulativeOutputTokens > 0))
{
promptTokens = Math.Max(0, _agentCumulativeInputTokens);
completionTokens = Math.Max(0, _agentCumulativeOutputTokens);
}
else
{
promptTokens = Math.Max(0, usage.PromptTokens);
completionTokens = Math.Max(0, usage.CompletionTokens);
}
}
StopAiIconPulse();
_cachedStreamContent = response;
draftSucceeded = true;
}
catch (OperationCanceledException)
{
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, cancelled: true);
assistantContent = finalized.Content;
draftCancelled = finalized.Cancelled;
draftFailure = finalized.FailureReason;
}
catch (Exception ex)
{
var finalized = _chatEngine.FinalizeExecutionContentForUi(assistantContent, ex);
assistantContent = finalized.Content;
draftFailure = finalized.FailureReason;
ShowToast("실패한 요청은 작업 요약에서 다시 시도할 수 있습니다.", "\uE783", 2600);
}
finally
{
ResetStreamingUiState();
}
lock (_convLock)
{
var session = ChatSession;
assistantContent = _chatEngine.FinalizeAssistantTurn(
session,
conversation,
runTab,
assistantContent,
promptTokens,
completionTokens,
responseElapsedMs,
assistantMetaRunId,
_storage);
_currentConversation = session?.CurrentConversation ?? conversation;
conversation = _currentConversation!;
}
FinalizeConversationTurn(rememberTab, conversation);
return new PreparedTurnOutcome(conversation, assistantContent, draftSucceeded, draftCancelled, draftFailure);
}
private void ScheduleExecutionHistoryRender(bool autoScroll = true)
{
_pendingExecutionHistoryAutoScroll |= autoScroll;
_executionHistoryRenderTimer.Stop();
_executionHistoryRenderTimer.Start();
}
private void ScheduleTaskSummaryRefresh()
{
_taskSummaryRefreshTimer.Stop();
_taskSummaryRefreshTimer.Start();
}
private void ScheduleConversationPersist(ChatConversation? conversation)
{
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
return;
_pendingConversationPersists[conversation.Id] = conversation;
_conversationPersistTimer.Stop();
_conversationPersistTimer.Start();
}
private void ScheduleAgentUiEvent(AgentEvent evt)
{
_pendingAgentUiEvent = evt;
_agentUiEventTimer.Stop();
_agentUiEventTimer.Start();
}
private void FlushPendingAgentUiEvent()
{
var evt = _pendingAgentUiEvent;
_pendingAgentUiEvent = null;
if (evt == null)
return;
UpdateStatusBar(evt);
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();
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);
}
}
}
private void PersistConversationSnapshot(string rememberTab, ChatConversation conversation, string failureLabel)
{
if (conversation == null || string.IsNullOrWhiteSpace(conversation.Id))
return;
_pendingConversationPersists.Remove(conversation.Id);
try
{
_storage.Save(conversation);
ChatSession?.RememberConversation(rememberTab, conversation.Id);
}
catch (Exception ex)
{
Services.LogService.Debug($"{failureLabel}: {ex.Message}");
}
}
private void FlushPendingConversationPersists()
{
if (_pendingConversationPersists.Count == 0)
return;
foreach (var conversation in _pendingConversationPersists.Values.ToList())
{
PersistConversationSnapshot(conversation.Tab ?? _activeTab, conversation, "대화 지연 저장 실패");
}
_pendingConversationPersists.Clear();
}
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
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("Only present a step-by-step plan when the user explicitly asks for a plan or when the task is too ambiguous to execute safely.");
sb.AppendLine("For ordinary Cowork requests, proceed directly with the work and focus on producing the real artifact.");
sb.AppendLine("After creating files, summarize what was created and include the actual output path.");
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: For reports, proposals, analyses, and manuals with multiple sections:");
sb.AppendLine(" 1. Decide the document structure internally first.");
sb.AppendLine(" 2. Use document_plan only when it improves the actual output, not as a mandatory user-facing approval step.");
sb.AppendLine(" 3. Then immediately create the real output file with html_create, docx_create, markdown_create, or file_write.");
sb.AppendLine(" 4. Fill every section with real content. Do not stop at an outline or plan only.");
sb.AppendLine(" 5. The task is complete only after the actual document file has been created or updated.");
// 문서 품질 검증 루프
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 (GetAutomaticFolderDataUsage())
{
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: Build an internal execution outline and impact assessment before editing.");
sb.AppendLine(" - Present the outline explicitly only when the user asks for a plan or the change is clearly high risk.");
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 and call out the impact clearly.");
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!;
// 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다.
// 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다.
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
AppendConversationExecutionEvent(evt, eventTab);
if (shouldShowExecutionHistory
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
ScheduleExecutionHistoryRender(autoScroll: true);
_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);
}
ScheduleAgentUiEvent(evt);
ScheduleTaskSummaryRefresh();
}
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
{
Dispatcher.Invoke(() =>
{
_appState.ApplySubAgentStatus(evt);
ScheduleTaskSummaryRefresh();
});
}
private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab)
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
var result = _chatEngine.AppendAgentRun(
session,
_storage,
_currentConversation,
_activeTab,
targetTab,
evt,
status,
summary);
_currentConversation = result.CurrentConversation;
ScheduleConversationPersist(result.UpdatedConversation);
}
}
private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab)
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
var result = _chatEngine.AppendExecutionEvent(
session,
_storage,
_currentConversation,
_activeTab,
targetTab,
evt);
_currentConversation = result.CurrentConversation;
ScheduleConversationPersist(result.UpdatedConversation);
}
}
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 static string GetRunStatusLabel(string? status)
=> status switch
{
"completed" => "완료",
"failed" => "실패",
"paused" => "일시중지",
_ => "진행 중",
};
private static string GetTaskStatusLabel(string? status)
=> status switch
{
"completed" => "완료",
"failed" => "실패",
"blocked" => "재시도 대기",
"waiting" => "승인 대기",
"cancelled" => "중단",
_ => "진행 중",
};
private string GetAgentItemDisplayName(string? rawName, bool slashPrefix = false)
=> GetTranscriptDisplayName(rawName, slashPrefix);
private static bool IsTranscriptToolLikeEvent(AgentEvent evt)
=> evt.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.SkillCall
|| (!string.IsNullOrWhiteSpace(evt.ToolName)
&& evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied);
private string BuildAgentEventSummaryText(AgentEvent evt, string displayName)
=> GetTranscriptEventSummary(evt, displayName);
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;
private TextBlock? _planToggleText;
/// <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 hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC");
var card = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(8, 2, 248, 5),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = GetMessageMaxWidth(),
};
var sp = new StackPanel();
// 헤더
var header = new Grid { Margin = new Thickness(0, 0, 0, 4) };
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var headerLeft = new StackPanel { Orientation = Orientation.Horizontal };
headerLeft.Children.Add(new TextBlock
{
Text = "\uE9D5", // plan icon
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 8,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center
});
headerLeft.Children.Add(new TextBlock
{
Text = $"계획 {steps.Count}단계",
FontSize = 9, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
});
header.Children.Add(headerLeft);
var toggleWrap = new Border
{
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(6, 2, 6, 2),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
};
_planToggleText = new TextBlock
{
Text = steps.Count > 0 ? "펼치기" : "",
FontSize = 8.25,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
};
toggleWrap.Child = _planToggleText;
Grid.SetColumn(toggleWrap, 1);
header.Children.Add(toggleWrap);
sp.Children.Add(header);
// 진행률 바
var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 6) };
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 = 3,
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 = 8, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(5, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(_planProgressText, 1);
progressGrid.Children.Add(_planProgressText);
sp.Children.Add(progressGrid);
// 단계 목록
_planStepsPanel = new StackPanel
{
Visibility = Visibility.Collapsed,
};
for (int i = 0; i < steps.Count; i++)
{
var stepRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 1, 0, 0),
Tag = i, // 인덱스 저장
};
stepRow.Children.Add(new TextBlock
{
Text = "○", // 빈 원 (미완료)
FontSize = 8.5,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
Tag = "status",
});
stepRow.Children.Add(new TextBlock
{
Text = $"{i + 1}. {steps[i]}",
FontSize = 8.75,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
MaxWidth = Math.Max(280, GetMessageMaxWidth() - 68),
VerticalAlignment = VerticalAlignment.Center,
});
_planStepsPanel.Children.Add(stepRow);
}
sp.Children.Add(_planStepsPanel);
toggleWrap.MouseLeftButtonUp += (_, _) =>
{
if (_planStepsPanel == null || _planToggleText == null)
return;
var expanded = _planStepsPanel.Visibility == Visibility.Visible;
_planStepsPanel.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible;
_planToggleText.Text = expanded ? "펼치기" : "접기";
toggleWrap.Background = expanded ? Brushes.Transparent : BrushFromHex("#F8FAFC");
};
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);
}
// ════════════════════════════════════════════════════════════
// 후속 작업 제안 칩 (suggest_actions)
// ════════════════════════════════════════════════════════════
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
private void RenderSuggestActionChips(string jsonSummary)
{
List<(string label, string command)> actions = new();
try
{
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 preview = string.Join(", ", actions.Take(3).Select(static action => action.label));
var suffix = actions.Count > 3 ? $" 외 {actions.Count - 3}개" : "";
ShowToast($"다음 작업 제안 준비됨: {preview}{suffix}");
}
// ════════════════════════════════════════════════════════════
// 피드백 학습 반영 (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;
}
/// <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);
}
}
RenderMessages(preserveViewport: true);
AutoScrollIfNeeded();
// 재전송
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)
{
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);
}
}
// 피드백을 사용자 메시지로 추가
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);
}
}
RenderMessages(preserveViewport: true);
AutoScrollIfNeeded();
// 재전송
await SendRegenerateAsync(conv);
}
private async Task SendRegenerateAsync(ChatConversation conv)
{
var runTab = NormalizeTabName(conv.Tab);
AxAgentExecutionEngine.PreparedExecution preparedExecution;
lock (_convLock)
preparedExecution = PrepareExecutionForConversation(conv, runTab, null);
await ExecutePreparedTurnAsync(
conv,
runTab,
conv.Tab ?? _activeTab,
preparedExecution,
"에이전트 작업 중...");
}
/// <summary>채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다.</summary>
private double GetMessageMaxWidth()
{
var hostWidth = _lastResponsiveComposerWidth;
if (hostWidth < 100)
hostWidth = ComposerShell?.ActualWidth ?? 0;
if (hostWidth < 100)
hostWidth = MessageScroll?.ActualWidth ?? 0;
if (hostWidth < 100)
hostWidth = 1120;
var maxW = hostWidth - 44;
return Math.Clamp(maxW, 320, 760);
}
private bool UpdateResponsiveChatLayout()
{
var viewportWidth = MessageScroll?.ActualWidth ?? 0;
if (viewportWidth < 200)
viewportWidth = ActualWidth;
if (viewportWidth < 200)
return false;
// claw-code처럼 메시지 축과 입력축이 같은 중심선을 공유하도록,
// 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다.
var contentWidth = Math.Max(360, viewportWidth - 24);
var messageWidth = Math.Clamp(contentWidth * 0.9, 360, 960);
var composerWidth = Math.Clamp(contentWidth * 0.86, 360, 900);
if (contentWidth < 760)
{
messageWidth = Math.Clamp(contentWidth - 10, 344, 820);
composerWidth = Math.Clamp(contentWidth - 14, 340, 780);
}
var changed = false;
if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 1)
{
_lastResponsiveMessageWidth = messageWidth;
if (MessagePanel != null)
MessagePanel.MaxWidth = messageWidth;
if (EmptyState != null)
EmptyState.MaxWidth = messageWidth;
changed = true;
}
if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 1)
{
_lastResponsiveComposerWidth = composerWidth;
if (ComposerShell != null)
{
ComposerShell.Width = composerWidth;
ComposerShell.MaxWidth = composerWidth;
}
changed = true;
}
return changed;
}
private StackPanel CreateStreamingContainer(out TextBlock streamText)
{
var msgMaxWidth = GetMessageMaxWidth();
var container = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Width = msgMaxWidth,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0, 3, 0, 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)
{
finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent;
// 스트리밍 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 (usageService, usageModel) = _llm.GetCurrentModelInfo();
var displayInput = isAgentTab && _agentCumulativeInputTokens > 0
? _agentCumulativeInputTokens
: usage?.PromptTokens ?? 0;
var displayOutput = isAgentTab && _agentCumulativeOutputTokens > 0
? _agentCumulativeOutputTokens
: usage?.CompletionTokens ?? 0;
var wasPostCompactionResponse = _pendingPostCompaction;
if (displayInput > 0 || displayOutput > 0)
{
UpdateStatusTokens(displayInput, displayOutput);
Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse);
ConsumePostCompactionUsageIfNeeded(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 postCompactSuffix = wasPostCompactionResponse ? " · compact 직후" : "";
var metaText = new TextBlock
{
Text = $"{elapsedText} · {tokenText}{postCompactSuffix}",
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 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>선택된 디자인 무드 키 (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();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
}
/// <summary>Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심.</summary>
private void BuildCodeBottomBar()
{
MoodIconPanel.Children.Clear();
if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed;
}
private Border CreateWorkspaceFolderBarButton()
{
var currentFolder = GetCurrentWorkFolder();
var label = string.IsNullOrWhiteSpace(currentFolder)
? "워크스페이스"
: TruncateForStatus(Path.GetFileName(currentFolder.TrimEnd('\\', '/')), 18);
var tooltip = string.IsNullOrWhiteSpace(currentFolder)
? "워크스페이스 선택"
: $"워크스페이스 선택\n현재: {currentFolder}";
return CreateFolderBarButton("\uE8B7", label, tooltip, "#4B5EFC");
}
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 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 = 24;
if (model.Length > maxLen)
model = model[..(maxLen - 1)] + "…";
ModelLabel.Text = string.IsNullOrWhiteSpace(model) ? serviceLabel : model;
if (BtnModelSelector != null)
BtnModelSelector.ToolTip = $"현재 모델: {GetCurrentModelDisplayName()}\n서비스: {serviceLabel}";
}
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 static bool SupportsOverlayRegisteredModels(string service)
=> string.Equals(service, "ollama", StringComparison.OrdinalIgnoreCase)
|| string.Equals(service, "vllm", StringComparison.OrdinalIgnoreCase);
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();
if (models.Count == 0)
{
CmbInlineModel.Items.Add(new ComboBoxItem { Content = "등록된 모델 없음", IsEnabled = false });
CmbInlineModel.SelectedIndex = 0;
}
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);
}
BtnInlineFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐");
BtnInlineReasoning.Content = GetQuickActionLabel("추론", ReasoningLabel(llm.AgentDecisionLevel));
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(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("basic");
Dispatcher.BeginInvoke(() =>
{
if (OverlayNavBasic != null)
OverlayNavBasic.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 = 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.Code.EnableCodeReview = ChkOverlayEnableCodeReview?.IsChecked == true;
llm.EnableImageInput = ChkOverlayEnableImageInput?.IsChecked == true;
llm.EnableParallelTools = ChkOverlayEnableParallelTools?.IsChecked == true;
llm.EnableProjectRules = ChkOverlayEnableProjectRules?.IsChecked == true;
llm.EnableAgentMemory = ChkOverlayEnableAgentMemory?.IsChecked == true;
llm.Code.EnableWorktreeTools = ChkOverlayEnableWorktreeTools?.IsChecked == true;
llm.Code.EnableTeamTools = ChkOverlayEnableTeamTools?.IsChecked == true;
llm.Code.EnableCronTools = ChkOverlayEnableCronTools?.IsChecked == true;
llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer?.IsChecked == true;
llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats?.IsChecked == true;
llm.EnableAuditLog = ChkOverlayEnableAuditLog?.IsChecked == true;
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);
CommitOverlayTemperatureInput(normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxRetryOnError, llm.MaxRetryOnError, 0, 10, value => llm.MaxRetryOnError = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, llm.MaxAgentIterations, 1, 200, value => llm.MaxAgentIterations = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, llm.FreeTierDelaySeconds, 0, 60, value => llm.FreeTierDelaySeconds = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxSubAgents, llm.MaxSubAgents, 1, 10, value => llm.MaxSubAgents = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, llm.ToolHookTimeoutMs, 3000, 30000, value => llm.ToolHookTimeoutMs = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, llm.MaxFavoriteSlashCommands, 1, 30, value => llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, llm.MaxRecentSlashCommands, 5, 50, value => llm.MaxRecentSlashCommands = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, llm.PlanDiffSeverityMediumCount, 1, 999, value => llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, llm.PlanDiffSeverityHighCount, 1, 999, value => llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: true);
CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: true);
if (TxtOverlayPdfExportPath != null)
llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim();
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);
PopulateOverlayMoodCombo();
if (loadDeferredInputs)
{
if (ChkOverlayAiEnabled != null)
ChkOverlayAiEnabled.IsChecked = true;
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 (TxtOverlayTemperature != null)
TxtOverlayTemperature.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
if (SldOverlayTemperature != null)
SldOverlayTemperature.Value = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1);
if (TxtOverlayTemperatureValue != null)
TxtOverlayTemperatureValue.Text = Math.Round(Math.Clamp(llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
if (TxtOverlayMaxRetryOnError != null)
TxtOverlayMaxRetryOnError.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString();
if (SldOverlayMaxRetryOnError != null)
SldOverlayMaxRetryOnError.Value = Math.Clamp(llm.MaxRetryOnError, 0, 10);
if (TxtOverlayMaxRetryOnErrorValue != null)
TxtOverlayMaxRetryOnErrorValue.Text = Math.Clamp(llm.MaxRetryOnError, 0, 10).ToString();
if (TxtOverlayMaxAgentIterations != null)
TxtOverlayMaxAgentIterations.Text = Math.Clamp(llm.MaxAgentIterations, 1, 200).ToString();
if (SldOverlayMaxAgentIterations != null)
SldOverlayMaxAgentIterations.Value = Math.Clamp(llm.MaxAgentIterations, 1, 100);
if (TxtOverlayMaxAgentIterationsValue != null)
TxtOverlayMaxAgentIterationsValue.Text = Math.Clamp(llm.MaxAgentIterations, 1, 100).ToString();
if (TxtOverlayFreeTierDelaySeconds != null)
TxtOverlayFreeTierDelaySeconds.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString();
if (SldOverlayFreeTierDelaySeconds != null)
SldOverlayFreeTierDelaySeconds.Value = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60);
if (TxtOverlayFreeTierDelaySecondsValue != null)
TxtOverlayFreeTierDelaySecondsValue.Text = Math.Clamp(llm.FreeTierDelaySeconds, 0, 60).ToString();
if (TxtOverlayMaxSubAgents != null)
TxtOverlayMaxSubAgents.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString();
if (SldOverlayMaxSubAgents != null)
SldOverlayMaxSubAgents.Value = Math.Clamp(llm.MaxSubAgents, 1, 10);
if (TxtOverlayMaxSubAgentsValue != null)
TxtOverlayMaxSubAgentsValue.Text = Math.Clamp(llm.MaxSubAgents, 1, 10).ToString();
if (TxtOverlayToolHookTimeoutMs != null)
TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString();
if (SldOverlayToolHookTimeoutMs != null)
SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000);
if (TxtOverlayToolHookTimeoutMsValue != null)
TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s";
if (TxtOverlayMaxFavoriteSlashCommands != null)
TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
if (SldOverlayMaxFavoriteSlashCommands != null)
SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30);
if (TxtOverlayMaxFavoriteSlashCommandsValue != null)
TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
if (TxtOverlayMaxRecentSlashCommands != null)
TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
if (SldOverlayMaxRecentSlashCommands != null)
SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50);
if (TxtOverlayMaxRecentSlashCommandsValue != null)
TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
if (TxtOverlayPlanDiffMediumCount != null)
TxtOverlayPlanDiffMediumCount.Text = Math.Clamp(llm.PlanDiffSeverityMediumCount, 1, 999).ToString();
if (TxtOverlayPlanDiffHighCount != null)
TxtOverlayPlanDiffHighCount.Text = Math.Clamp(llm.PlanDiffSeverityHighCount, 1, 999).ToString();
if (TxtOverlayPlanDiffMediumRatio != null)
TxtOverlayPlanDiffMediumRatio.Text = Math.Clamp(llm.PlanDiffSeverityMediumRatioPercent, 1, 100).ToString();
if (TxtOverlayPlanDiffHighRatio != null)
TxtOverlayPlanDiffHighRatio.Text = Math.Clamp(llm.PlanDiffSeverityHighRatioPercent, 1, 100).ToString();
if (TxtOverlayPdfExportPath != null)
TxtOverlayPdfExportPath.Text = llm.PdfExportPath ?? "";
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 (ChkOverlayEnableCodeReview != null)
ChkOverlayEnableCodeReview.IsChecked = llm.Code.EnableCodeReview;
if (ChkOverlayEnableImageInput != null)
ChkOverlayEnableImageInput.IsChecked = llm.EnableImageInput;
if (ChkOverlayEnableParallelTools != null)
ChkOverlayEnableParallelTools.IsChecked = llm.EnableParallelTools;
if (ChkOverlayEnableProjectRules != null)
ChkOverlayEnableProjectRules.IsChecked = llm.EnableProjectRules;
if (ChkOverlayEnableAgentMemory != null)
ChkOverlayEnableAgentMemory.IsChecked = llm.EnableAgentMemory;
if (ChkOverlayEnableWorktreeTools != null)
ChkOverlayEnableWorktreeTools.IsChecked = llm.Code.EnableWorktreeTools;
if (ChkOverlayEnableTeamTools != null)
ChkOverlayEnableTeamTools.IsChecked = llm.Code.EnableTeamTools;
if (ChkOverlayEnableCronTools != null)
ChkOverlayEnableCronTools.IsChecked = llm.Code.EnableCronTools;
if (ChkOverlayWorkflowVisualizer != null)
ChkOverlayWorkflowVisualizer.IsChecked = llm.WorkflowVisualizer;
if (ChkOverlayShowTotalCallStats != null)
ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats;
if (ChkOverlayEnableAuditLog != null)
ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog;
}
RefreshOverlayThemeCards();
RefreshOverlayServiceCards();
RefreshOverlayModeButtons();
RefreshOverlayTokenPresetCards();
RefreshOverlayServiceFieldLabels(service);
RefreshOverlayServiceFieldVisibility(service);
BuildOverlayRegisteredModelsPanel(service);
RefreshOverlayAdvancedChoiceButtons();
RefreshOverlayRetentionButtons();
RefreshOverlayStorageSummary();
}
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 LlmSettings? TryGetOverlayLlmSettings()
=> _settings?.Settings?.Llm;
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 = 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 bool CommitOverlayTemperatureInput(bool normalizeOnInvalid)
{
if (TxtOverlayTemperature == null)
return false;
var raw = TxtOverlayTemperature.Text.Trim();
if (!double.TryParse(raw, out var parsed))
{
ClearOverlayValidation(TxtOverlayTemperature);
if (normalizeOnInvalid)
TxtOverlayTemperature.Text = Math.Round(Math.Clamp(_settings.Settings.Llm.Temperature, 0.0, 2.0), 1).ToString("0.0");
return false;
}
var normalized = Math.Round(Math.Clamp(parsed, 0.0, 2.0), 1);
var changed = Math.Abs(_settings.Settings.Llm.Temperature - normalized) > 0.0001;
_settings.Settings.Llm.Temperature = normalized;
ClearOverlayValidation(TxtOverlayTemperature);
if (normalizeOnInvalid || changed)
TxtOverlayTemperature.Text = normalized.ToString("0.0");
return changed;
}
private void TxtOverlayTemperature_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayTemperatureInput(normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayTemperature_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = Math.Round(Math.Clamp(e.NewValue, 0.0, 2.0), 1);
llm.Temperature = value;
if (TxtOverlayTemperature != null)
TxtOverlayTemperature.Text = value.ToString("0.0");
if (TxtOverlayTemperatureValue != null)
TxtOverlayTemperatureValue.Text = value.ToString("0.0");
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayMaxRetryOnError_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 10));
llm.MaxRetryOnError = value;
if (TxtOverlayMaxRetryOnError != null)
TxtOverlayMaxRetryOnError.Text = value.ToString();
if (TxtOverlayMaxRetryOnErrorValue != null)
TxtOverlayMaxRetryOnErrorValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayMaxAgentIterations_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 100));
llm.MaxAgentIterations = value;
if (TxtOverlayMaxAgentIterations != null)
TxtOverlayMaxAgentIterations.Text = value.ToString();
if (TxtOverlayMaxAgentIterationsValue != null)
TxtOverlayMaxAgentIterationsValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayFreeTierDelaySeconds_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 0, 60));
llm.FreeTierDelaySeconds = value;
if (TxtOverlayFreeTierDelaySeconds != null)
TxtOverlayFreeTierDelaySeconds.Text = value.ToString();
if (TxtOverlayFreeTierDelaySecondsValue != null)
TxtOverlayFreeTierDelaySecondsValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayMaxSubAgents_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 10));
llm.MaxSubAgents = value;
if (TxtOverlayMaxSubAgents != null)
TxtOverlayMaxSubAgents.Text = value.ToString();
if (TxtOverlayMaxSubAgentsValue != null)
TxtOverlayMaxSubAgentsValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayToolHookTimeoutMs_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 3000, 30000));
llm.ToolHookTimeoutMs = value;
if (TxtOverlayToolHookTimeoutMs != null)
TxtOverlayToolHookTimeoutMs.Text = value.ToString();
if (TxtOverlayToolHookTimeoutMsValue != null)
TxtOverlayToolHookTimeoutMsValue.Text = $"{value / 1000}s";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlaySlashPopupPageSize_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 3, 20));
llm.SlashPopupPageSize = value;
if (TxtOverlaySlashPopupPageSize != null)
TxtOverlaySlashPopupPageSize.Text = value.ToString();
if (TxtOverlaySlashPopupPageSizeValue != null)
TxtOverlaySlashPopupPageSizeValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayMaxFavoriteSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 1, 30));
llm.MaxFavoriteSlashCommands = value;
if (TxtOverlayMaxFavoriteSlashCommands != null)
TxtOverlayMaxFavoriteSlashCommands.Text = value.ToString();
if (TxtOverlayMaxFavoriteSlashCommandsValue != null)
TxtOverlayMaxFavoriteSlashCommandsValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void SldOverlayMaxRecentSlashCommands_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
var llm = TryGetOverlayLlmSettings();
if (_isOverlaySettingsSyncing || llm == null)
return;
var value = (int)Math.Round(Math.Clamp(e.NewValue, 5, 50));
llm.MaxRecentSlashCommands = value;
if (TxtOverlayMaxRecentSlashCommands != null)
TxtOverlayMaxRecentSlashCommands.Text = value.ToString();
if (TxtOverlayMaxRecentSlashCommandsValue != null)
TxtOverlayMaxRecentSlashCommandsValue.Text = value.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayCompactPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value))
return;
_settings.Settings.Llm.ContextCompactTriggerPercent = Math.Clamp(value, 10, 95);
if (TxtOverlayContextCompactTriggerPercent != null)
TxtOverlayContextCompactTriggerPercent.Text = _settings.Settings.Llm.ContextCompactTriggerPercent.ToString();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayContextPresetCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
if (sender is not Border border || border.Tag is not string tag || !int.TryParse(tag, out var value))
return;
_settings.Settings.Llm.MaxContextTokens = Math.Clamp(value, 1024, 1_000_000);
if (TxtOverlayMaxContextTokens != null)
TxtOverlayMaxContextTokens.Text = _settings.Settings.Llm.MaxContextTokens.ToString();
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 TxtOverlayMaxAgentIterations_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxAgentIterations, _settings.Settings.Llm.MaxAgentIterations, 1, 200, value => _settings.Settings.Llm.MaxAgentIterations = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayFreeTierDelaySeconds_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayFreeTierDelaySeconds, _settings.Settings.Llm.FreeTierDelaySeconds, 0, 60, value => _settings.Settings.Llm.FreeTierDelaySeconds = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayMaxSubAgents_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxSubAgents, _settings.Settings.Llm.MaxSubAgents, 1, 10, value => _settings.Settings.Llm.MaxSubAgents = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlaySlashPopupPageSize_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlaySlashPopupPageSize, _settings.Settings.Llm.SlashPopupPageSize, 3, 20, value => _settings.Settings.Llm.SlashPopupPageSize = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayToolHookTimeoutMs_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayToolHookTimeoutMs, _settings.Settings.Llm.ToolHookTimeoutMs, 3000, 30000, value => _settings.Settings.Llm.ToolHookTimeoutMs = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayMaxFavoriteSlashCommands_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxFavoriteSlashCommands, _settings.Settings.Llm.MaxFavoriteSlashCommands, 1, 30, value => _settings.Settings.Llm.MaxFavoriteSlashCommands = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayMaxRecentSlashCommands_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayMaxRecentSlashCommands, _settings.Settings.Llm.MaxRecentSlashCommands, 5, 50, value => _settings.Settings.Llm.MaxRecentSlashCommands = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayPdfExportPath_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || TxtOverlayPdfExportPath == null)
return;
_settings.Settings.Llm.PdfExportPath = TxtOverlayPdfExportPath.Text.Trim();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayPlanDiffMediumCount_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumCount, _settings.Settings.Llm.PlanDiffSeverityMediumCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityMediumCount = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayPlanDiffHighCount_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighCount, _settings.Settings.Llm.PlanDiffSeverityHighCount, 1, 999, value => _settings.Settings.Llm.PlanDiffSeverityHighCount = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayPlanDiffMediumRatio_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayPlanDiffMediumRatio, _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityMediumRatioPercent = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void TxtOverlayPlanDiffHighRatio_LostFocus(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
return;
if (CommitOverlayNumericInput(TxtOverlayPlanDiffHighRatio, _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent, 1, 100, value => _settings.Settings.Llm.PlanDiffSeverityHighRatioPercent = value, normalizeOnInvalid: false))
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayBrowseSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
var dlg = new System.Windows.Forms.FolderBrowserDialog
{
Description = "스킬 파일이 있는 폴더를 선택하세요",
ShowNewFolderButton = true,
};
var current = _settings.Settings.Llm.SkillsFolderPath;
if (!string.IsNullOrWhiteSpace(current) && Directory.Exists(current))
dlg.SelectedPath = current;
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return;
_settings.Settings.Llm.SkillsFolderPath = dlg.SelectedPath;
SkillService.LoadSkills(dlg.SelectedPath);
RefreshOverlayEtcPanels();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayOpenSkillFolderBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
var folder = !string.IsNullOrWhiteSpace(_settings.Settings.Llm.SkillsFolderPath) && Directory.Exists(_settings.Settings.Llm.SkillsFolderPath)
? _settings.Settings.Llm.SkillsFolderPath
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AxCopilot", "skills");
if (!Directory.Exists(folder))
Directory.CreateDirectory(folder);
try { System.Diagnostics.Process.Start("explorer.exe", folder); } catch { }
}
private void OverlayAddHookBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> ShowOverlayHookEditDialog(null, -1);
private void OverlayOpenAuditLogBtn_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
try { System.Diagnostics.Process.Start("explorer.exe", Services.AuditLogService.GetAuditFolder()); } catch { }
}
private void ChkOverlayEnableDragDropAiActions_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableDragDropAiActions == null)
return;
_settings.Settings.Llm.EnableDragDropAiActions = ChkOverlayEnableDragDropAiActions.IsChecked == true;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayDragDropAutoSend_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayDragDropAutoSend == null)
return;
_settings.Settings.Llm.DragDropAutoSend = ChkOverlayDragDropAutoSend.IsChecked == true;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayWorkflowVisualizer_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayWorkflowVisualizer == null)
return;
_settings.Settings.Llm.WorkflowVisualizer = ChkOverlayWorkflowVisualizer.IsChecked == true;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayShowTotalCallStats_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayShowTotalCallStats == null)
return;
_settings.Settings.Llm.ShowTotalCallStats = ChkOverlayShowTotalCallStats.IsChecked == true;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayEnableAuditLog_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableAuditLog == null)
return;
_settings.Settings.Llm.EnableAuditLog = ChkOverlayEnableAuditLog.IsChecked == true;
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) ? "basic" : tag.Trim().ToLowerInvariant();
var showBasic = section == "basic";
var showChat = section == "chat";
var showShared = section == "shared";
var showCowork = section == "cowork";
var showCode = section == "code";
var showDev = section == "dev";
var showTools = section == "tools";
var showSkill = section == "skill";
var showBlock = section == "block";
OverlaySectionService.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
OverlaySectionQuick.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed;
OverlaySectionDetail.Visibility = Visibility.Visible;
var headingTitle = section switch
{
"chat" => "채팅 설정",
"shared" => "코워크/코드 공통 설정",
"cowork" => "코워크 설정",
"code" => "코드 설정",
"dev" => "개발자 설정",
"tools" => "도구 설정",
"skill" => "스킬 설정",
"block" => "차단 설정",
_ => "공통 설정"
};
var headingDescription = section switch
{
"chat" => "Chat 탭에서 쓰는 입력/내보내기 같은 채팅 전용 설정입니다.",
"shared" => "Cowork와 Code에서 함께 쓰는 문맥/압축 관련 기본 설정입니다.",
"cowork" => "문서/업무 협업 흐름에 맞춘 코워크 전용 설정입니다.",
"code" => "코드 작업, 검증, 개발 도구 사용에 맞춘 설정입니다.",
"dev" => "실행 이력, 감사, 시각화 같은 개발자용 설정입니다.",
"tools" => "AX Agent가 사용할 도구와 훅 동작을 관리합니다.",
"skill" => "슬래시 스킬, 스킬 폴더, 폴백 모델, MCP 연결을 관리합니다.",
"block" => "에이전트가 접근하거나 수정하면 안 되는 경로와 형식을 관리합니다.",
_ => "Chat, Cowork, Code에서 공통으로 쓰는 기본 설정입니다."
};
if (OverlayTopHeadingTitle != null)
OverlayTopHeadingTitle.Text = headingTitle;
if (OverlayTopHeadingDescription != null)
OverlayTopHeadingDescription.Text = headingDescription;
if (OverlayAnchorCommon != null)
OverlayAnchorCommon.Text = headingTitle;
if (OverlayAiEnabledRow != null)
OverlayAiEnabledRow.Visibility = Visibility.Collapsed;
if (OverlayThemePanel != null)
OverlayThemePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
if (OverlayThemeStylePanel != null)
OverlayThemeStylePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
if (OverlayDefaultOutputFormatRow != null)
OverlayDefaultOutputFormatRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
if (OverlayDefaultMoodRow != null)
OverlayDefaultMoodRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
if (OverlayPdfExportPathRow != null)
OverlayPdfExportPathRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleImageInput != null)
OverlayToggleImageInput.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed;
if (OverlayModelEditorPanel != null)
OverlayModelEditorPanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
if (OverlayAnchorPermission != null)
OverlayAnchorPermission.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed;
if (OverlayTlsRow != null)
OverlayTlsRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed;
if (OverlayAnchorAdvanced != null)
OverlayAnchorAdvanced.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed;
if (TxtOverlayContextCompactTriggerPercent != null)
TxtOverlayContextCompactTriggerPercent.Visibility = Visibility.Collapsed;
if (OverlayMaxContextTokensRow != null)
OverlayMaxContextTokensRow.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed;
if (OverlayTemperatureRow != null)
OverlayTemperatureRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayMaxRetryRow != null)
OverlayMaxRetryRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayMaxAgentIterationsRow != null)
OverlayMaxAgentIterationsRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayDeveloperRuntimePanel != null)
OverlayDeveloperRuntimePanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayDeveloperExtraPanel != null)
OverlayDeveloperExtraPanel.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayAdvancedTogglePanel != null)
OverlayAdvancedTogglePanel.Visibility = showDev || showCowork || showCode || showTools || showSkill ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToolsInfoPanel != null)
OverlayToolsInfoPanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToolsRuntimePanel != null)
OverlayToolsRuntimePanel.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToolRegistrySection != null)
OverlayToolRegistrySection.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
if (OverlaySkillInfoPanel != null)
OverlaySkillInfoPanel.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed;
if (OverlaySkillRuntimePanel != null)
OverlaySkillRuntimePanel.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed;
if (OverlayBlockInfoPanel != null)
OverlayBlockInfoPanel.Visibility = showBlock ? Visibility.Visible : Visibility.Collapsed;
if (OverlayBlockRuntimePanel != null)
OverlayBlockRuntimePanel.Visibility = showBlock ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleProactiveCompact != null)
OverlayToggleProactiveCompact.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleSkillSystem != null)
OverlayToggleSkillSystem.Visibility = showSkill ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleToolHooks != null)
OverlayToggleToolHooks.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleHookInputMutation != null)
OverlayToggleHookInputMutation.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleHookPermissionUpdate != null)
OverlayToggleHookPermissionUpdate.Visibility = showTools ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCoworkVerification != null)
OverlayToggleCoworkVerification.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCodeVerification != null)
OverlayToggleCodeVerification.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCodeReview != null)
OverlayToggleCodeReview.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleParallelTools != null)
OverlayToggleParallelTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleProjectRules != null)
OverlayToggleProjectRules.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleAgentMemory != null)
OverlayToggleAgentMemory.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleWorktreeTools != null)
OverlayToggleWorktreeTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleTeamTools != null)
OverlayToggleTeamTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (OverlayToggleCronTools != null)
OverlayToggleCronTools.Visibility = showCode ? Visibility.Visible : Visibility.Collapsed;
if (showTools || showSkill || showBlock)
RefreshOverlayEtcPanels();
}
private void RefreshOverlaySettingsPanel()
{
RefreshOverlayVisualState(loadDeferredInputs: true);
RefreshOverlayEtcPanels();
}
private void RefreshOverlayRetentionButtons()
{
ApplyOverlayRetentionButtonState(BtnOverlayRetention7, _settings.Settings.Llm.RetentionDays == 7);
ApplyOverlayRetentionButtonState(BtnOverlayRetention30, _settings.Settings.Llm.RetentionDays == 30);
ApplyOverlayRetentionButtonState(BtnOverlayRetention90, _settings.Settings.Llm.RetentionDays == 90);
ApplyOverlayRetentionButtonState(BtnOverlayRetentionUnlimited, _settings.Settings.Llm.RetentionDays == 0);
}
private void ApplyOverlayRetentionButtonState(Button? button, bool selected)
{
if (button == null)
return;
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
var border = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
var hint = TryFindResource("HintBackground") as Brush ?? Brushes.Transparent;
var primary = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondary = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
button.Background = selected ? hint : Brushes.Transparent;
button.BorderBrush = selected ? accent : border;
button.BorderThickness = new Thickness(1);
button.Foreground = selected ? accent : primary;
button.FontWeight = selected ? FontWeights.SemiBold : FontWeights.Normal;
button.Cursor = Cursors.Hand;
}
private void RefreshOverlayStorageSummary()
{
if (OverlayStorageSummaryText == null || OverlayStorageDriveText == null)
return;
var report = StorageAnalyzer.Analyze();
var appTotal = report.Conversations + report.AuditLogs + report.Logs + report.CodeIndex + report.EmbeddingDb + report.ClipboardHistory + report.Plugins + report.Skills + report.Settings;
OverlayStorageSummaryText.Text = $"앱 전체 사용량: {FormatStorageBytes(appTotal)}";
if (!string.IsNullOrWhiteSpace(report.DriveLabel) && report.DriveTotalSpace > 0)
{
var used = report.DriveTotalSpace - report.DriveFreeSpace;
var percent = report.DriveTotalSpace == 0 ? 0 : (int)Math.Round((double)used / report.DriveTotalSpace * 100);
OverlayStorageDriveText.Text = $"{report.DriveLabel} · 사용 {percent}% · 여유 {FormatStorageBytes(report.DriveFreeSpace)}";
}
else
{
OverlayStorageDriveText.Text = "로컬 앱 데이터 폴더 기준 사용량입니다.";
}
}
private void BtnOverlayRetention_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement element)
return;
var retainDays = element.Name switch
{
"BtnOverlayRetention7" => 7,
"BtnOverlayRetention30" => 30,
"BtnOverlayRetention90" => 90,
"BtnOverlayRetentionUnlimited" => 0,
_ => _settings.Settings.Llm.RetentionDays
};
_settings.Settings.Llm.RetentionDays = retainDays;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
RefreshOverlayRetentionButtons();
}
private void BtnOverlayStorageRefresh_Click(object sender, RoutedEventArgs e)
{
RefreshOverlayStorageSummary();
}
private void BtnOverlayDeleteAllConversations_Click(object sender, RoutedEventArgs e)
{
BtnDeleteAll_Click(sender, e);
RefreshOverlayStorageSummary();
}
private void BtnOverlayStorageCleanup_Click(object sender, RoutedEventArgs e)
{
var retainDays = Math.Max(0, _settings.Settings.Llm.RetentionDays);
var cleanedBytes = StorageAnalyzer.Cleanup(
retainDays,
cleanConversations: false,
cleanAuditLogs: true,
cleanLogs: true,
cleanCodeIndex: true,
cleanClipboard: true);
RefreshOverlayStorageSummary();
CustomMessageBox.Show(
cleanedBytes > 0
? $"저장 공간을 정리했습니다.\n확보된 공간: {StorageAnalyzer.FormatSize(cleanedBytes)}"
: "정리할 항목이 없었습니다.",
"저장 공간 정리",
MessageBoxButton.OK,
MessageBoxImage.Information);
}
private static string FormatStorageBytes(long bytes)
{
if (bytes >= 1024L * 1024 * 1024)
return $"{bytes / 1024.0 / 1024 / 1024:F1} GB";
if (bytes >= 1024L * 1024)
return $"{bytes / 1024.0 / 1024:F1} MB";
if (bytes >= 1024L)
return $"{bytes / 1024.0:F0} KB";
return $"{bytes} B";
}
private void RefreshOverlayEtcPanels()
{
var llm = _settings.Settings.Llm;
if (OverlaySkillsFolderPathText != null)
{
var defaultFolder = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot",
"skills");
OverlaySkillsFolderPathText.Text = string.IsNullOrWhiteSpace(llm.SkillsFolderPath)
? defaultFolder
: llm.SkillsFolderPath.Trim();
}
if (TxtOverlaySlashPopupPageSize != null)
TxtOverlaySlashPopupPageSize.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString();
if (SldOverlaySlashPopupPageSize != null)
SldOverlaySlashPopupPageSize.Value = Math.Clamp(llm.SlashPopupPageSize, 3, 20);
if (TxtOverlaySlashPopupPageSizeValue != null)
TxtOverlaySlashPopupPageSizeValue.Text = Math.Clamp(llm.SlashPopupPageSize, 3, 20).ToString();
if (TxtOverlayToolHookTimeoutMs != null)
TxtOverlayToolHookTimeoutMs.Text = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000).ToString();
if (SldOverlayToolHookTimeoutMs != null)
SldOverlayToolHookTimeoutMs.Value = Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000);
if (TxtOverlayToolHookTimeoutMsValue != null)
TxtOverlayToolHookTimeoutMsValue.Text = $"{Math.Clamp(llm.ToolHookTimeoutMs, 3000, 30000) / 1000}s";
if (TxtOverlayMaxFavoriteSlashCommands != null)
TxtOverlayMaxFavoriteSlashCommands.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
if (SldOverlayMaxFavoriteSlashCommands != null)
SldOverlayMaxFavoriteSlashCommands.Value = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30);
if (TxtOverlayMaxFavoriteSlashCommandsValue != null)
TxtOverlayMaxFavoriteSlashCommandsValue.Text = Math.Clamp(llm.MaxFavoriteSlashCommands, 1, 30).ToString();
if (TxtOverlayMaxRecentSlashCommands != null)
TxtOverlayMaxRecentSlashCommands.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
if (SldOverlayMaxRecentSlashCommands != null)
SldOverlayMaxRecentSlashCommands.Value = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50);
if (TxtOverlayMaxRecentSlashCommandsValue != null)
TxtOverlayMaxRecentSlashCommandsValue.Text = Math.Clamp(llm.MaxRecentSlashCommands, 5, 50).ToString();
if (ChkOverlayEnableDragDropAiActions != null)
ChkOverlayEnableDragDropAiActions.IsChecked = llm.EnableDragDropAiActions;
if (ChkOverlayDragDropAutoSend != null)
ChkOverlayDragDropAutoSend.IsChecked = llm.DragDropAutoSend;
BuildOverlayBlockedItems();
BuildOverlayHookCards();
BuildOverlaySkillListPanel();
BuildOverlayFallbackModelsPanel();
BuildOverlayMcpServerCards();
BuildOverlayToolRegistryPanel();
}
private void BuildOverlayBlockedItems()
{
if (OverlayBlockedPathsPanel != null)
{
OverlayBlockedPathsPanel.Children.Clear();
foreach (var path in _settings.Settings.Llm.BlockedPaths.Where(x => !string.IsNullOrWhiteSpace(x)))
{
OverlayBlockedPathsPanel.Children.Add(new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 0, 0, 4),
Child = new TextBlock
{
Text = path,
FontSize = 11.5,
FontFamily = new FontFamily("Consolas, Malgun Gothic"),
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray
}
});
}
}
if (OverlayBlockedExtensionsPanel != null)
{
OverlayBlockedExtensionsPanel.Children.Clear();
foreach (var ext in _settings.Settings.Llm.BlockedExtensions.Where(x => !string.IsNullOrWhiteSpace(x)))
{
OverlayBlockedExtensionsPanel.Children.Add(new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(6),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 0, 6, 6),
Child = new TextBlock
{
Text = ext,
FontSize = 11.5,
FontFamily = new FontFamily("Consolas, Malgun Gothic"),
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray
}
});
}
}
}
private void BuildOverlaySkillListPanel()
{
if (OverlaySkillListPanel == null)
return;
OverlaySkillListPanel.Children.Clear();
var skills = SkillService.Skills.ToList();
if (skills.Count == 0)
{
OverlaySkillListPanel.Children.Add(new TextBlock
{
Text = "로드된 스킬이 없습니다.",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
});
return;
}
var unavailable = skills
.Where(skill => !skill.IsAvailable)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var autoSkills = skills
.Where(skill => skill.IsAvailable && (!skill.UserInvocable || !string.IsNullOrWhiteSpace(skill.Paths)))
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
var directSkills = skills
.Where(skill => skill.IsAvailable && skill.UserInvocable && string.IsNullOrWhiteSpace(skill.Paths))
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
AddOverlaySkillSection("overlay-skill-direct", "직접 호출 스킬", "슬래시(/)로 직접 실행하는 스킬입니다.", directSkills, "#2563EB");
AddOverlaySkillSection("overlay-skill-auto", "자동/조건부 스킬", "조건에 따라 자동으로 붙거나 보조적으로 동작하는 스킬입니다.", autoSkills, "#0F766E");
AddOverlaySkillSection("overlay-skill-unavailable", "현재 사용 불가", "필요한 런타임이 없어 지금은 호출되지 않는 스킬입니다.", unavailable, "#9A3412");
}
private void AddOverlaySkillSection(string key, string title, string subtitle, List<SkillDefinition> skills, string accentHex)
{
if (OverlaySkillListPanel == null || skills.Count == 0)
return;
var body = new StackPanel();
foreach (var skill in skills)
{
var card = new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 0, 0, 6),
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = "/" + skill.Name,
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black
});
if (!string.IsNullOrWhiteSpace(skill.Label) &&
!string.Equals(skill.Label.Trim(), skill.Name, StringComparison.OrdinalIgnoreCase))
{
stack.Children.Add(new TextBlock
{
Text = skill.Label.Trim(),
Margin = new Thickness(0, 3, 0, 0),
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
});
}
stack.Children.Add(new TextBlock
{
Text = string.IsNullOrWhiteSpace(skill.Description)
? (string.IsNullOrWhiteSpace(skill.Label) ? skill.Name : skill.Label)
: skill.Description,
Margin = new Thickness(0, 4, 0, 0),
FontSize = 11,
TextWrapping = TextWrapping.Wrap,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
});
card.Child = stack;
body.Children.Add(card);
}
OverlaySkillListPanel.Children.Add(CreateOverlayCollapsibleSection(
key,
$"{title} ({skills.Count})",
subtitle,
body,
defaultExpanded: false,
accentHex: accentHex));
}
private void BuildOverlayFallbackModelsPanel()
{
if (OverlayFallbackModelsPanel == null)
return;
OverlayFallbackModelsPanel.Children.Clear();
var llm = _settings.Settings.Llm;
var sections = new[]
{
("ollama", "Ollama"),
("vllm", "vLLM"),
("gemini", "Gemini"),
("claude", "Claude")
};
foreach (var (service, label) in sections)
{
var candidates = GetModelCandidates(service);
OverlayFallbackModelsPanel.Children.Add(new TextBlock
{
Text = label,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black,
Margin = new Thickness(0, 0, 0, 6)
});
if (candidates.Count == 0)
{
OverlayFallbackModelsPanel.Children.Add(new TextBlock
{
Text = "등록된 모델 없음",
FontSize = 10.5,
Margin = new Thickness(8, 0, 0, 8),
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
});
continue;
}
foreach (var candidate in candidates)
{
var enabled = llm.FallbackModels.Any(x => x.Equals(candidate.Id, StringComparison.OrdinalIgnoreCase));
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var nameText = new TextBlock
{
Text = candidate.Label,
FontSize = 11.5,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black
};
var stateText = new TextBlock
{
Text = enabled ? "사용" : "미사용",
FontSize = 10.5,
Foreground = enabled
? (TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue)
: (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray),
Margin = new Thickness(12, 0, 0, 0)
};
Grid.SetColumn(stateText, 1);
grid.Children.Add(nameText);
grid.Children.Add(stateText);
OverlayFallbackModelsPanel.Children.Add(new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 7, 10, 7),
Margin = new Thickness(0, 0, 0, 6),
Child = grid
});
}
}
}
private void BuildOverlayMcpServerCards()
{
if (OverlayMcpServerListPanel == null)
return;
OverlayMcpServerListPanel.Children.Clear();
var servers = _settings.Settings.Llm.McpServers;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.White;
var cardBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
if (servers == null || servers.Count == 0)
{
OverlayMcpServerListPanel.Children.Add(new TextBlock
{
Text = "등록된 MCP 서버가 없습니다.",
FontSize = 11,
Foreground = secondaryText
});
return;
}
Border CreateActionChip(string text, Brush foreground, Action onClick)
{
var border = new Border
{
Background = Brushes.Transparent,
BorderBrush = Brushes.Transparent,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(6, 0, 0, 0),
Cursor = Cursors.Hand,
};
var label = new TextBlock
{
Text = text,
FontSize = 11,
FontWeight = FontWeights.SemiBold,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center,
};
border.Child = label;
border.MouseEnter += (_, _) =>
{
border.Background = TryFindResource("HintBackground") as Brush ?? new SolidColorBrush(Color.FromArgb(20, 0, 0, 0));
border.BorderBrush = borderBrush;
};
border.MouseLeave += (_, _) =>
{
border.Background = Brushes.Transparent;
border.BorderBrush = Brushes.Transparent;
};
border.MouseLeftButtonUp += (_, _) => onClick();
return border;
}
for (int index = 0; index < servers.Count; index++)
{
var server = servers[index];
var card = new Border
{
Background = cardBackground,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 6)
};
var root = new StackPanel();
var header = new Grid();
header.ColumnDefinitions.Add(new ColumnDefinition());
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var title = new TextBlock
{
Text = server.Name,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
VerticalAlignment = VerticalAlignment.Center
};
header.Children.Add(title);
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
};
actions.Children.Add(CreateActionChip(server.Enabled ? "비활성화" : "활성화", accentBrush, () =>
{
_settings.Settings.Llm.McpServers[index].Enabled = !_settings.Settings.Llm.McpServers[index].Enabled;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
BuildOverlayMcpServerCards();
}));
actions.Children.Add(CreateActionChip("삭제", BrushFromHex("#DC2626"), () =>
{
var result = CustomMessageBox.Show($"'{server.Name}' 서버를 삭제하시겠습니까?", "MCP 서버 삭제",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
return;
_settings.Settings.Llm.McpServers.RemoveAt(index);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
BuildOverlayMcpServerCards();
}));
Grid.SetColumn(actions, 1);
header.Children.Add(actions);
root.Children.Add(header);
var commandCard = new Border
{
Background = itemBackground,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 8, 0, 0),
Child = new StackPanel
{
Children =
{
new TextBlock
{
Text = server.Command,
FontSize = 10.8,
TextWrapping = TextWrapping.Wrap,
Foreground = primaryText,
},
new TextBlock
{
Text = server.Enabled ? "활성 상태" : "비활성 상태",
Margin = new Thickness(0, 4, 0, 0),
FontSize = 10.5,
Foreground = secondaryText,
}
}
}
};
root.Children.Add(commandCard);
card.Child = root;
OverlayMcpServerListPanel.Children.Add(card);
}
}
private void BtnOverlayAddMcpServer_Click(object sender, RoutedEventArgs e)
{
var nameDialog = new InputDialog("MCP 서버 추가", "서버 이름:", placeholder: "예: my-mcp-server")
{
Owner = this
};
if (nameDialog.ShowDialog() != true || string.IsNullOrWhiteSpace(nameDialog.ResponseText))
return;
var commandDialog = new InputDialog("MCP 서버 추가", "실행 명령:", placeholder: "예: npx -y @modelcontextprotocol/server-filesystem")
{
Owner = this
};
if (commandDialog.ShowDialog() != true || string.IsNullOrWhiteSpace(commandDialog.ResponseText))
return;
_settings.Settings.Llm.McpServers.Add(new Models.McpServerEntry
{
Name = nameDialog.ResponseText.Trim(),
Command = commandDialog.ResponseText.Trim(),
Enabled = true,
});
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
BuildOverlayMcpServerCards();
}
private void BuildOverlayToolRegistryPanel()
{
if (OverlayToolRegistryPanel == null)
return;
OverlayToolRegistryPanel.Children.Clear();
var grouped = _toolRegistry.All
.GroupBy(tool => GetOverlayToolCategory(tool.Name))
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var group in grouped)
{
var body = new StackPanel();
foreach (var tool in group.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
var isDisabled = _settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase);
var row = new Border
{
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.White,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 0, 0, 6),
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var rowStack = new StackPanel { Margin = new Thickness(0, 0, 12, 0) };
rowStack.Children.Add(new TextBlock
{
Text = tool.Name,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = isDisabled
? (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray)
: (TryFindResource("PrimaryText") as Brush ?? Brushes.Black)
});
rowStack.Children.Add(new TextBlock
{
Text = (isDisabled ? "비활성" : "활성") + " · " + tool.Description,
Margin = new Thickness(0, 4, 0, 0),
FontSize = 10.8,
TextWrapping = TextWrapping.Wrap,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
});
grid.Children.Add(rowStack);
var toggle = new CheckBox
{
IsChecked = !isDisabled,
VerticalAlignment = VerticalAlignment.Center,
Style = TryFindResource("ToggleSwitch") as Style,
};
toggle.Checked += (_, _) =>
{
_settings.Settings.Llm.DisabledTools.RemoveAll(name => string.Equals(name, tool.Name, StringComparison.OrdinalIgnoreCase));
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
RefreshOverlayEtcPanels();
};
toggle.Unchecked += (_, _) =>
{
if (!_settings.Settings.Llm.DisabledTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase))
_settings.Settings.Llm.DisabledTools.Add(tool.Name);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
RefreshOverlayEtcPanels();
};
Grid.SetColumn(toggle, 1);
grid.Children.Add(toggle);
row.Child = grid;
body.Children.Add(row);
}
OverlayToolRegistryPanel.Children.Add(CreateOverlayCollapsibleSection(
"overlay-tool-" + group.Key,
$"{group.Key} ({group.Count()})",
"카테고리별 도구 목록입니다. 펼치면 상세 이름과 사용 여부를 바로 바꿀 수 있습니다.",
body,
defaultExpanded: false,
accentHex: "#4F46E5"));
}
}
private void BuildOverlayHookCards()
{
if (OverlayHookListPanel == null)
return;
OverlayHookListPanel.Children.Clear();
var hooks = _settings.Settings.Llm.AgentHooks;
if (hooks.Count == 0)
{
OverlayHookListPanel.Children.Add(new TextBlock
{
Text = "등록된 훅이 없습니다.",
FontSize = 11,
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray
});
return;
}
var body = new StackPanel();
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
for (int i = 0; i < hooks.Count; i++)
{
var hook = hooks[i];
var idx = i;
var card = new Border
{
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke,
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(0, 0, 0, 6),
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition());
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var toggle = new CheckBox
{
IsChecked = hook.Enabled,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0),
Style = TryFindResource("ToggleSwitch") as Style,
};
toggle.Checked += (_, _) =>
{
hook.Enabled = true;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
};
toggle.Unchecked += (_, _) =>
{
hook.Enabled = false;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
};
Grid.SetColumn(toggle, 0);
grid.Children.Add(toggle);
var info = new StackPanel();
var header = new StackPanel { Orientation = Orientation.Horizontal };
header.Children.Add(new TextBlock
{
Text = hook.Name,
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
header.Children.Add(new Border
{
Background = BrushFromHex(hook.Timing == "pre" ? "#FFEDD5" : "#DCFCE7"),
BorderBrush = BrushFromHex(hook.Timing == "pre" ? "#FDBA74" : "#86EFAC"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(5),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(6, 0, 0, 0),
Child = new TextBlock
{
Text = hook.Timing == "pre" ? "PRE" : "POST",
FontSize = 9.5,
FontWeight = FontWeights.Bold,
Foreground = BrushFromHex(hook.Timing == "pre" ? "#9A3412" : "#166534"),
}
});
if (!string.IsNullOrWhiteSpace(hook.ToolName) && hook.ToolName != "*")
{
header.Children.Add(new Border
{
Background = BrushFromHex("#EEF2FF"),
BorderBrush = BrushFromHex("#C7D2FE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(5),
Padding = new Thickness(5, 1, 5, 1),
Margin = new Thickness(6, 0, 0, 0),
Child = new TextBlock
{
Text = hook.ToolName,
FontSize = 9.5,
Foreground = BrushFromHex("#3730A3"),
}
});
}
info.Children.Add(header);
info.Children.Add(new TextBlock
{
Text = Path.GetFileName(hook.ScriptPath),
Margin = new Thickness(0, 4, 0, 0),
FontSize = 11,
Foreground = secondaryText
});
if (!string.IsNullOrWhiteSpace(hook.Arguments))
{
info.Children.Add(new TextBlock
{
Text = hook.Arguments,
Margin = new Thickness(0, 3, 0, 0),
FontSize = 10.5,
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText
});
}
Grid.SetColumn(info, 1);
grid.Children.Add(info);
var editBtn = new Border
{
Cursor = Cursors.Hand,
Padding = new Thickness(6),
Margin = new Thickness(6, 0, 2, 0),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "\uE70F",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = accentBrush,
}
};
editBtn.MouseLeftButtonUp += (_, _) => ShowOverlayHookEditDialog(hooks[idx], idx);
Grid.SetColumn(editBtn, 2);
grid.Children.Add(editBtn);
var deleteBtn = new Border
{
Cursor = Cursors.Hand,
Padding = new Thickness(6),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "\uE74D",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 12,
Foreground = BrushFromHex("#DC2626"),
}
};
deleteBtn.MouseLeftButtonUp += (_, _) =>
{
hooks.RemoveAt(idx);
BuildOverlayHookCards();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
};
Grid.SetColumn(deleteBtn, 3);
grid.Children.Add(deleteBtn);
card.Child = grid;
body.Children.Add(card);
}
OverlayHookListPanel.Children.Add(CreateOverlayCollapsibleSection(
"overlay-hooks",
$"등록된 훅 ({hooks.Count})",
"도구 실행 전후에 연결되는 스크립트입니다.",
body,
defaultExpanded: false,
accentHex: "#0F766E"));
}
private Border CreateOverlayCollapsibleSection(string key, string title, string subtitle, UIElement content, bool defaultExpanded, string accentHex)
{
var itemBackground = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
var launcherBackground = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var expanded = _overlaySectionExpandedStates.TryGetValue(key, out var stored) ? stored : defaultExpanded;
var bodyBorder = new Border
{
Margin = new Thickness(0, 10, 0, 0),
Child = content,
Visibility = expanded ? Visibility.Visible : Visibility.Collapsed
};
var caret = new TextBlock
{
Text = expanded ? "\uE70D" : "\uE76C",
FontFamily = new FontFamily("Segoe MDL2 Assets"),
FontSize = 11,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center
};
var headerGrid = new Grid();
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var headerText = new StackPanel();
headerText.Children.Add(new TextBlock
{
Text = title,
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText
});
if (!string.IsNullOrWhiteSpace(subtitle))
{
headerText.Children.Add(new TextBlock
{
Text = subtitle,
Margin = new Thickness(0, 4, 0, 0),
FontSize = 10.8,
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText
});
}
headerGrid.Children.Add(headerText);
Grid.SetColumn(caret, 1);
headerGrid.Children.Add(caret);
var headerBorder = new Border
{
Background = launcherBackground,
BorderBrush = BrushFromHex(accentHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(10, 8, 10, 8),
Cursor = Cursors.Hand,
Child = headerGrid
};
void Toggle()
{
var nextExpanded = bodyBorder.Visibility != Visibility.Visible;
bodyBorder.Visibility = nextExpanded ? Visibility.Visible : Visibility.Collapsed;
caret.Text = nextExpanded ? "\uE70D" : "\uE76C";
_overlaySectionExpandedStates[key] = nextExpanded;
}
headerBorder.MouseLeftButtonUp += (_, _) => Toggle();
return new Border
{
Background = itemBackground,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10),
Margin = new Thickness(0, 0, 0, 8),
Child = new StackPanel
{
Children =
{
headerBorder,
bodyBorder
}
}
};
}
private static TextBlock CreateOverlayPlaceholder(string text, Brush foreground, string? currentValue)
{
return new TextBlock
{
Text = text,
FontSize = 13,
Foreground = foreground,
Opacity = 0.45,
IsHitTestVisible = false,
VerticalAlignment = VerticalAlignment.Center,
Padding = new Thickness(14, 8, 14, 8),
Visibility = string.IsNullOrEmpty(currentValue) ? Visibility.Visible : Visibility.Collapsed,
};
}
private void ShowOverlayHookEditDialog(AgentHookEntry? existing, int index)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? BrushFromHex("#FFFFFF");
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.LightGray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var isNew = existing == null;
var dlg = new Window
{
Title = isNew ? "훅 추가" : "훅 편집",
Width = 420,
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 = isNew ? "훅 추가" : "훅 편집",
FontSize = 15,
FontWeight = FontWeights.SemiBold,
Foreground = fgBrush,
Margin = new Thickness(0, 0, 0, 14)
});
dlg.KeyDown += (_, e) => { if (e.Key == Key.Escape) dlg.Close(); };
stack.Children.Add(new TextBlock { Text = "훅 이름", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 0, 0, 4) });
var nameBox = new TextBox
{
Text = existing?.Name ?? "",
FontSize = 13,
Foreground = fgBrush,
Background = itemBg,
BorderBrush = borderBrush,
Padding = new Thickness(12, 8, 12, 8)
};
var nameHolder = CreateOverlayPlaceholder("예: 코드 리뷰 후 알림", subFgBrush, existing?.Name);
nameBox.TextChanged += (_, _) => nameHolder.Visibility = string.IsNullOrEmpty(nameBox.Text) ? Visibility.Visible : Visibility.Collapsed;
var nameGrid = new Grid();
nameGrid.Children.Add(nameBox);
nameGrid.Children.Add(nameHolder);
stack.Children.Add(nameGrid);
stack.Children.Add(new TextBlock { Text = "대상 도구 (* = 모든 도구)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
var toolBox = new TextBox
{
Text = existing?.ToolName ?? "*",
FontSize = 13,
Foreground = fgBrush,
Background = itemBg,
BorderBrush = borderBrush,
Padding = new Thickness(12, 8, 12, 8)
};
var toolHolder = CreateOverlayPlaceholder("예: file_write, grep_tool", subFgBrush, existing?.ToolName ?? "*");
toolBox.TextChanged += (_, _) => toolHolder.Visibility = string.IsNullOrEmpty(toolBox.Text) ? Visibility.Visible : Visibility.Collapsed;
var toolGrid = new Grid();
toolGrid.Children.Add(toolBox);
toolGrid.Children.Add(toolHolder);
stack.Children.Add(toolGrid);
stack.Children.Add(new TextBlock { Text = "실행 타이밍", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
var timingPanel = new StackPanel { Orientation = Orientation.Horizontal };
var preRadio = new RadioButton
{
Content = "Pre (실행 전)",
Foreground = fgBrush,
FontSize = 13,
Margin = new Thickness(0, 0, 16, 0),
IsChecked = (existing?.Timing ?? "post") == "pre"
};
var postRadio = new RadioButton
{
Content = "Post (실행 후)",
Foreground = fgBrush,
FontSize = 13,
IsChecked = (existing?.Timing ?? "post") != "pre"
};
timingPanel.Children.Add(preRadio);
timingPanel.Children.Add(postRadio);
stack.Children.Add(timingPanel);
stack.Children.Add(new TextBlock { Text = "스크립트 경로 (.bat / .cmd / .ps1)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
var pathGrid = new Grid();
pathGrid.ColumnDefinitions.Add(new ColumnDefinition());
pathGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var pathInnerGrid = new Grid();
var pathBox = new TextBox
{
Text = existing?.ScriptPath ?? "",
FontSize = 13,
Foreground = fgBrush,
Background = itemBg,
BorderBrush = borderBrush,
Padding = new Thickness(12, 8, 12, 8)
};
var pathHolder = CreateOverlayPlaceholder("예: C:\\scripts\\review-notify.bat", subFgBrush, existing?.ScriptPath);
pathBox.TextChanged += (_, _) => pathHolder.Visibility = string.IsNullOrEmpty(pathBox.Text) ? Visibility.Visible : Visibility.Collapsed;
pathInnerGrid.Children.Add(pathBox);
pathInnerGrid.Children.Add(pathHolder);
pathGrid.Children.Add(pathInnerGrid);
var browseBtn = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(6),
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(6, 0, 0, 0),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "...",
FontSize = 13,
Foreground = accentBrush
}
};
browseBtn.MouseLeftButtonUp += (_, _) =>
{
var ofd = new OpenFileDialog
{
Filter = "스크립트 파일|*.bat;*.cmd;*.ps1|모든 파일|*.*",
Title = "훅 스크립트 선택",
};
if (ofd.ShowDialog() == true)
pathBox.Text = ofd.FileName;
};
Grid.SetColumn(browseBtn, 1);
pathGrid.Children.Add(browseBtn);
stack.Children.Add(pathGrid);
stack.Children.Add(new TextBlock { Text = "추가 인수 (선택)", FontSize = 12, Foreground = subFgBrush, Margin = new Thickness(0, 10, 0, 4) });
var argsBox = new TextBox
{
Text = existing?.Arguments ?? "",
FontSize = 13,
Foreground = fgBrush,
Background = itemBg,
BorderBrush = borderBrush,
Padding = new Thickness(12, 8, 12, 8)
};
var argsHolder = CreateOverlayPlaceholder("예: --verbose --output log.txt", subFgBrush, existing?.Arguments);
argsBox.TextChanged += (_, _) => argsHolder.Visibility = string.IsNullOrEmpty(argsBox.Text) ? Visibility.Visible : Visibility.Collapsed;
var argsGrid = new Grid();
argsGrid.Children.Add(argsBox);
argsGrid.Children.Add(argsHolder);
stack.Children.Add(argsGrid);
var btnRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 16, 0, 0)
};
var cancelBorder = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(16, 8, 16, 8),
Margin = new Thickness(0, 0, 8, 0),
Cursor = Cursors.Hand,
Child = new TextBlock { Text = "취소", FontSize = 13, Foreground = subFgBrush }
};
cancelBorder.MouseLeftButtonUp += (_, _) => dlg.Close();
btnRow.Children.Add(cancelBorder);
var saveBorder = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(16, 8, 16, 8),
Cursor = Cursors.Hand,
Child = new TextBlock
{
Text = isNew ? "추가" : "저장",
FontSize = 13,
Foreground = Brushes.White,
FontWeight = FontWeights.SemiBold
}
};
saveBorder.MouseLeftButtonUp += (_, _) =>
{
if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(pathBox.Text))
{
CustomMessageBox.Show("훅 이름과 스크립트 경로를 입력하세요.", "입력 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var entry = new AgentHookEntry
{
Name = nameBox.Text.Trim(),
ToolName = string.IsNullOrWhiteSpace(toolBox.Text) ? "*" : toolBox.Text.Trim(),
Timing = preRadio.IsChecked == true ? "pre" : "post",
ScriptPath = pathBox.Text.Trim(),
Arguments = argsBox.Text.Trim(),
Enabled = existing?.Enabled ?? true,
};
var hooks = _settings.Settings.Llm.AgentHooks;
if (isNew)
hooks.Add(entry);
else if (index >= 0 && index < hooks.Count)
hooks[index] = entry;
BuildOverlayHookCards();
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
dlg.Close();
};
btnRow.Children.Add(saveBorder);
stack.Children.Add(btnRow);
border.Child = stack;
dlg.Content = border;
dlg.ShowDialog();
}
private static string GetOverlayToolCategory(string toolName)
{
return toolName switch
{
"file_read" or "file_write" or "file_edit" or "glob" or "grep_tool" or "folder_map" or "document_read" or "file_manage" or "file_info" or "multi_read"
=> "파일/검색",
"process" or "build_run" or "dev_env_detect" or "snippet_runner"
=> "프로세스/빌드",
"search_codebase" or "code_search" or "code_review" or "lsp" or "test_loop" or "git_tool" or "project_rules" or "project_rule" or "diff_preview"
=> "코드 분석",
"excel_create" or "docx_create" or "csv_create" or "markdown_create" or "html_create" or "chart_create" or "batch_create" or "pptx_create" or "document_review" or "format_convert" or "document_planner" or "document_assembler" or "template_render"
=> "문서 생성",
"json_tool" or "regex_tool" or "diff_tool" or "base64_tool" or "hash_tool" or "datetime_tool" or "math_tool" or "xml_tool" or "sql_tool" or "data_pivot" or "text_summarize"
=> "데이터 처리",
"clipboard_tool" or "notify_tool" or "env_tool" or "zip_tool" or "http_tool" or "open_external" or "image_analyze" or "file_watch"
=> "시스템/환경",
"spawn_agent" or "wait_agents" or "memory" or "skill_manager" or "user_ask" or "task_tracker" or "todo_write" or "task_create" or "task_get" or "task_list" or "task_update" or "task_stop" or "task_output" or "enter_plan_mode" or "exit_plan_mode" or "enter_worktree" or "exit_worktree" or "team_create" or "team_delete" or "cron_create" or "cron_delete" or "cron_list" or "suggest_actions" or "checkpoint" or "playbook"
=> "에이전트",
_ => "기타"
};
}
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(OverlayThemeStyleNordCard, preset == "nord");
SetOverlayCardSelection(OverlayThemeStyleEmberCard, preset == "ember");
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 RefreshOverlayTokenPresetCards()
{
var llm = _settings.Settings.Llm;
var compact = llm.ContextCompactTriggerPercent switch
{
<= 60 => 60,
<= 70 => 70,
<= 80 => 80,
_ => 90
};
SetOverlayCardSelection(OverlayCompact60Card, compact == 60);
SetOverlayCardSelection(OverlayCompact70Card, compact == 70);
SetOverlayCardSelection(OverlayCompact80Card, compact == 80);
SetOverlayCardSelection(OverlayCompact90Card, compact == 90);
var context = llm.MaxContextTokens switch
{
<= 4096 => 4096,
<= 16384 => 16384,
<= 32768 => 32768,
<= 65536 => 65536,
<= 131072 => 131072,
<= 262144 => 262144,
_ => 1_000_000
};
SetOverlayCardSelection(OverlayContext4KCard, context == 4096);
SetOverlayCardSelection(OverlayContext16KCard, context == 16384);
SetOverlayCardSelection(OverlayContext32KCard, context == 32768);
SetOverlayCardSelection(OverlayContext64KCard, context == 65536);
SetOverlayCardSelection(OverlayContext128KCard, context == 131072);
SetOverlayCardSelection(OverlayContext256KCard, context == 262144);
SetOverlayCardSelection(OverlayContext1MCard, context == 1_000_000);
}
private void RefreshOverlayModeButtons()
{
var llm = _settings.Settings.Llm;
SelectComboTag(CmbOverlayOperationMode, OperationModePolicy.Normalize(_settings.Settings.OperationMode));
SelectComboTag(CmbOverlayPermission, PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission));
SelectComboTag(CmbOverlayReasoning, llm.AgentDecisionLevel);
SelectComboTag(CmbOverlayFastMode, llm.FreeTierMode ? "on" : "off");
SelectComboTag(CmbOverlayDefaultOutputFormat, llm.DefaultOutputFormat ?? "auto");
SelectComboTag(CmbOverlayDefaultMood, _selectedMood ?? llm.DefaultMood ?? "modern");
SelectComboTag(CmbOverlayAgentLogLevel, llm.AgentLogLevel ?? "simple");
UpdateDataUsageUI();
}
private static void SelectComboTag(ComboBox? combo, string? tag)
{
if (combo == null) return;
var normalized = (tag ?? "").Trim();
combo.SelectedItem = combo.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag as string, normalized, StringComparison.OrdinalIgnoreCase));
}
private void PopulateOverlayMoodCombo()
{
if (CmbOverlayDefaultMood == null)
return;
var selected = _selectedMood ?? _settings.Settings.Llm.DefaultMood ?? "modern";
CmbOverlayDefaultMood.Items.Clear();
foreach (var mood in TemplateService.AllMoods)
{
CmbOverlayDefaultMood.Items.Add(new ComboBoxItem
{
Content = $"{mood.Icon} {mood.Label}",
Tag = mood.Key
});
}
SelectComboTag(CmbOverlayDefaultMood, selected);
}
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 void RefreshOverlayServiceFieldVisibility(string service)
{
if (OverlayEndpointFieldPanel == null || OverlayApiKeyFieldPanel == null)
return;
var hideEndpoint = string.Equals(service, "gemini", StringComparison.OrdinalIgnoreCase)
|| string.Equals(service, "claude", StringComparison.OrdinalIgnoreCase);
OverlayEndpointFieldPanel.Visibility = hideEndpoint ? Visibility.Collapsed : Visibility.Visible;
OverlayApiKeyFieldPanel.Margin = hideEndpoint ? new Thickness(0) : new Thickness(6, 0, 0, 0);
Grid.SetColumn(OverlayApiKeyFieldPanel, hideEndpoint ? 0 : 1);
Grid.SetColumnSpan(OverlayApiKeyFieldPanel, hideEndpoint ? 2 : 1);
}
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 BuildOverlayRegisteredModelsPanel(string service)
{
if (OverlayRegisteredModelsPanel == null || OverlayRegisteredModelsHeader == null || BtnOverlayAddModel == null)
return;
var normalized = NormalizeOverlayService(service);
var supportsRegistered = SupportsOverlayRegisteredModels(normalized);
OverlayRegisteredModelsHeader.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed;
OverlayRegisteredModelsPanel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed;
BtnOverlayAddModel.Visibility = supportsRegistered ? Visibility.Visible : Visibility.Collapsed;
OverlayRegisteredModelsPanel.Children.Clear();
if (!supportsRegistered)
return;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.White;
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
var models = _settings.Settings.Llm.RegisteredModels
.Where(m => string.Equals(m.Service, normalized, StringComparison.OrdinalIgnoreCase))
.OrderBy(m => m.Alias, StringComparer.OrdinalIgnoreCase)
.ToList();
if (models.Count == 0)
{
OverlayRegisteredModelsPanel.Children.Add(new Border
{
CornerRadius = new CornerRadius(10),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Background = Brushes.Transparent,
Padding = new Thickness(12, 10, 12, 10),
Child = new TextBlock
{
Text = "등록된 모델이 없습니다. `모델 추가`로 사내 모델을 먼저 등록하세요.",
FontSize = 11.5,
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
}
});
return;
}
foreach (var model in models)
{
var decryptedModelName = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled);
var displayName = string.IsNullOrWhiteSpace(model.Alias) ? decryptedModelName : model.Alias;
var endpointText = string.IsNullOrWhiteSpace(model.Endpoint) ? "기본 서버 사용" : model.Endpoint;
var authLabel = string.Equals(model.AuthType, "cp4d", StringComparison.OrdinalIgnoreCase) ? "CP4D" : "Bearer";
var isActive = string.Equals(model.EncryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase)
|| string.Equals(decryptedModelName, _settings.Settings.Llm.Model, StringComparison.OrdinalIgnoreCase);
var row = new Border
{
CornerRadius = new CornerRadius(10),
BorderBrush = isActive ? accentBrush : borderBrush,
BorderThickness = new Thickness(1),
Background = isActive ? (TryFindResource("HintBackground") as Brush ?? BrushFromHex("#EFF6FF")) : itemBg,
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 0, 0, 8),
};
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var info = new StackPanel();
info.Children.Add(new TextBlock
{
Text = displayName,
FontSize = 12,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
info.Children.Add(new TextBlock
{
Text = string.IsNullOrWhiteSpace(decryptedModelName) ? "(모델명 없음)" : decryptedModelName,
Margin = new Thickness(0, 3, 0, 0),
FontSize = 11,
Foreground = secondaryText,
});
info.Children.Add(new TextBlock
{
Text = $"엔드포인트: {endpointText} · 인증: {authLabel}",
Margin = new Thickness(0, 4, 0, 0),
FontSize = 10.5,
TextWrapping = TextWrapping.Wrap,
Foreground = secondaryText,
});
Grid.SetColumn(info, 0);
grid.Children.Add(info);
var actions = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
};
Border CreateAction(string text, Action onClick, Brush foreground)
{
var label = new TextBlock
{
Text = text,
FontSize = 11.5,
FontWeight = FontWeights.SemiBold,
Foreground = foreground,
VerticalAlignment = VerticalAlignment.Center,
};
var action = new Border
{
Cursor = Cursors.Hand,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(6, 0, 0, 0),
Background = Brushes.Transparent,
Child = label,
};
action.MouseEnter += (_, _) =>
{
action.Background = hoverBg;
};
action.MouseLeave += (_, _) =>
{
action.Background = Brushes.Transparent;
};
action.MouseLeftButtonUp += (_, _) => onClick();
return action;
}
actions.Children.Add(CreateAction("선택", () =>
{
CommitOverlayModelSelection(model.EncryptedModelName);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}, accentBrush));
actions.Children.Add(CreateAction("편집", () =>
{
EditOverlayRegisteredModel(model);
BuildOverlayRegisteredModelsPanel(service);
}, primaryText));
actions.Children.Add(CreateAction("삭제", () =>
{
DeleteOverlayRegisteredModel(model);
}, BrushFromHex("#DC2626")));
Grid.SetColumn(actions, 1);
grid.Children.Add(actions);
row.Child = grid;
row.MouseEnter += (_, _) =>
{
if (!isActive)
row.Background = hoverBg;
};
row.MouseLeave += (_, _) =>
{
if (!isActive)
row.Background = itemBg;
};
OverlayRegisteredModelsPanel.Children.Add(row);
}
}
private void BtnOverlayAddModel_Click(object sender, RoutedEventArgs e)
{
var service = NormalizeOverlayService(_settings.Settings.Llm.Service);
if (!SupportsOverlayRegisteredModels(service))
return;
var dlg = new ModelRegistrationDialog(service) { Owner = this };
if (dlg.ShowDialog() != true)
return;
_settings.Settings.Llm.RegisteredModels.Add(new RegisteredModel
{
Alias = dlg.ModelAlias,
EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled),
Service = service,
Endpoint = dlg.Endpoint,
ApiKey = dlg.ApiKey,
AllowInsecureTls = dlg.AllowInsecureTls,
AuthType = dlg.AuthType,
Cp4dUrl = dlg.Cp4dUrl,
Cp4dUsername = dlg.Cp4dUsername,
Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled),
});
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
}
private void EditOverlayRegisteredModel(RegisteredModel model)
{
var currentModel = Services.CryptoService.DecryptIfEnabled(model.EncryptedModelName, IsOverlayEncryptionEnabled);
var cp4dPassword = Services.CryptoService.DecryptIfEnabled(model.Cp4dPassword ?? "", IsOverlayEncryptionEnabled);
var service = NormalizeOverlayService(model.Service);
var dlg = new ModelRegistrationDialog(
service,
model.Alias,
currentModel,
model.Endpoint,
model.ApiKey,
model.AllowInsecureTls,
model.AuthType ?? "bearer",
model.Cp4dUrl ?? "",
model.Cp4dUsername ?? "",
cp4dPassword)
{ Owner = this };
if (dlg.ShowDialog() != true)
return;
model.Alias = dlg.ModelAlias;
model.EncryptedModelName = Services.CryptoService.EncryptIfEnabled(dlg.ModelName, IsOverlayEncryptionEnabled);
model.Service = service;
model.Endpoint = dlg.Endpoint;
model.ApiKey = dlg.ApiKey;
model.AllowInsecureTls = dlg.AllowInsecureTls;
model.AuthType = dlg.AuthType;
model.Cp4dUrl = dlg.Cp4dUrl;
model.Cp4dUsername = dlg.Cp4dUsername;
model.Cp4dPassword = Services.CryptoService.EncryptIfEnabled(dlg.Cp4dPassword, IsOverlayEncryptionEnabled);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
}
private void DeleteOverlayRegisteredModel(RegisteredModel model)
{
var result = CustomMessageBox.Show($"'{model.Alias}' 모델을 삭제하시겠습니까?", "모델 삭제",
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
return;
_settings.Settings.Llm.RegisteredModels.Remove(model);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: true);
}
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 OverlayThemeStyleNordCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentThemePreset = "nord";
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void OverlayThemeStyleEmberCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_settings.Settings.Llm.AgentThemePreset = "ember";
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);
RefreshOverlayVisualState(loadDeferredInputs: 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 = GetAutomaticFolderDataUsage();
}
private void CmbOverlayFastMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayFastMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
_settings.Settings.Llm.FreeTierMode = string.Equals(tag, "on", StringComparison.OrdinalIgnoreCase);
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void CmbOverlayReasoning_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayReasoning.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
_settings.Settings.Llm.AgentDecisionLevel = tag;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void CmbOverlayPermission_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayPermission.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
var normalized = PermissionModeCatalog.NormalizeGlobalMode(tag);
var llm = _settings.Settings.Llm;
llm.FilePermission = normalized;
llm.DefaultAgentPermission = normalized;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void CmbOverlayDefaultOutputFormat_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayDefaultOutputFormat.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
_settings.Settings.Llm.DefaultOutputFormat = tag;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void CmbOverlayDefaultMood_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayDefaultMood.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
_selectedMood = tag;
_settings.Settings.Llm.DefaultMood = tag;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void CmbOverlayOperationMode_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayOperationMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
var normalized = OperationModePolicy.Normalize(tag);
var current = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
if (string.Equals(normalized, current, StringComparison.OrdinalIgnoreCase))
return;
if (string.Equals(normalized, OperationModePolicy.ExternalMode, StringComparison.OrdinalIgnoreCase) &&
!PromptOverlayPasswordDialog("운영 모드 변경", "사내/사외 모드 변경", "비밀번호를 입력하세요."))
{
RefreshOverlayModeButtons();
return;
}
_settings.Settings.OperationMode = normalized;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void CmbOverlayFolderDataUsage_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
_folderDataUsage = GetAutomaticFolderDataUsage();
}
private void CmbOverlayAgentLogLevel_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_isOverlaySettingsSyncing || CmbOverlayAgentLogLevel.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag)
return;
_settings.Settings.Llm.AgentLogLevel = tag;
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 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 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 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 static string BuildUsageModelKey(string? service, string? model)
{
var normalizedService = (service ?? "").Trim().ToLowerInvariant();
var normalizedModel = (model ?? "").Trim();
if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel))
return "";
return $"{normalizedService}:{normalizedModel}";
}
private static long GetUsageValue(Dictionary<string, long>? source, string key)
{
if (source == null || string.IsNullOrWhiteSpace(key))
return 0;
return source.TryGetValue(key, out var value) ? value : 0;
}
private string BuildTopModelUsageSummary(DailyUsageStats todayUsage)
{
if (todayUsage.ModelPromptTokens.Count == 0 && todayUsage.ModelCompletionTokens.Count == 0)
return "";
var totals = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in todayUsage.ModelPromptTokens)
totals[pair.Key] = pair.Value;
foreach (var pair in todayUsage.ModelCompletionTokens)
totals[pair.Key] = totals.TryGetValue(pair.Key, out var current) ? current + pair.Value : pair.Value;
var topEntries = totals
.OrderByDescending(pair => pair.Value)
.Take(3)
.Select(pair =>
{
var label = pair.Key.Replace(":", " · ");
var postPrompt = GetUsageValue(todayUsage.PostCompactionPromptTokens, pair.Key);
var postCompletion = GetUsageValue(todayUsage.PostCompactionCompletionTokens, pair.Key);
var postTotal = postPrompt + postCompletion;
return postTotal > 0
? $"- {label}: {FormatTokenCount(pair.Value)} tokens (compact 이후 {FormatTokenCount(postTotal)})"
: $"- {label}: {FormatTokenCount(pair.Value)} tokens";
})
.ToList();
return topEntries.Count == 0
? ""
: "\n오늘 상위 모델\n" + string.Join("\n", topEntries);
}
private static string FormatTokenCount(long value)
{
if (value <= int.MaxValue)
return Services.TokenEstimator.Format((int)value);
if (value >= 1_000_000)
return $"{value / 1_000_000d:0.#}M";
if (value >= 1_000)
return $"{value / 1_000d:0.#}K";
return value.ToString("N0");
}
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(ContextCompactionResult result, bool wasAutomatic)
{
var beforeTokens = Math.Max(0, result.BeforeTokens);
var afterTokens = Math.Max(0, result.AfterTokens);
var savedTokens = Math.Max(0, beforeTokens - afterTokens);
_lastCompactionBeforeTokens = beforeTokens;
_lastCompactionAfterTokens = afterTokens;
_lastCompactionAt = DateTime.Now;
_lastCompactionWasAutomatic = wasAutomatic;
_lastCompactionStageSummary = result.StageSummary;
_sessionCompactionCount++;
_sessionCompactionSavedTokens += savedTokens;
_sessionMemoryCompactionCount += result.SessionMemoryApplied ? 1 : 0;
_sessionMicrocompactBoundaryCount += result.MicrocompactBoundaryCount;
_sessionSnipCompactionCount += result.SnippedMessageCount + result.CollapsedBoundaryCount;
_pendingPostCompaction = true;
if (wasAutomatic)
_sessionAutomaticCompactionCount++;
else
_sessionManualCompactionCount++;
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null)
return;
conv.CompactionCount = _sessionCompactionCount;
conv.AutomaticCompactionCount = _sessionAutomaticCompactionCount;
conv.ManualCompactionCount = _sessionManualCompactionCount;
conv.CompactionSavedTokens = _sessionCompactionSavedTokens;
conv.SessionMemoryCompactionCount = _sessionMemoryCompactionCount;
conv.MicrocompactBoundaryCount = _sessionMicrocompactBoundaryCount;
conv.SnipCompactionCount = _sessionSnipCompactionCount;
conv.LastCompactionAt = _lastCompactionAt;
conv.LastCompactionWasAutomatic = wasAutomatic;
conv.LastCompactionBeforeTokens = beforeTokens;
conv.LastCompactionAfterTokens = afterTokens;
conv.LastCompactionStageSummary = _lastCompactionStageSummary;
conv.PendingPostCompaction = true;
try { _storage.Save(conv); } catch { }
}
private void ConsumePostCompactionUsageIfNeeded(int promptTokens, int completionTokens)
{
if (!_pendingPostCompaction)
return;
_pendingPostCompaction = false;
_sessionPostCompactionResponseCount++;
_sessionPostCompactionPromptTokens += Math.Max(0, promptTokens);
_sessionPostCompactionCompletionTokens += Math.Max(0, completionTokens);
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
if (conv == null)
return;
conv.PendingPostCompaction = false;
conv.PostCompactionResponseCount = _sessionPostCompactionResponseCount;
conv.PostCompactionPromptTokens = _sessionPostCompactionPromptTokens;
conv.PostCompactionCompletionTokens = _sessionPostCompactionCompletionTokens;
try { _storage.Save(conv); } catch { }
}
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 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 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 DraftQueueItem? EnqueueDraftRequest(string text, string priority, string? explicitKind = null)
{
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;
}
return queuedItem;
}
// ─── 헬퍼 ─────────────────────────────────────────────────────────────
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();
UpdateInputBoxHeight();
RefreshDraftQueueUi();
}
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)
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;
}
RefreshDraftQueueUi();
_ = SendMessageAsync(next.Text);
}
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 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);
EnqueueDraftRequest(prompt, "next", "followup");
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);
EnqueueDraftRequest(prompt, "next", "followup");
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))
return;
if (_isStreaming)
{
EnqueueDraftRequest(lastUserMessage, "now", "direct");
RefreshDraftQueueUi();
ShowToast("현재 작업 뒤에 같은 요청을 다시 실행하도록 대기열에 추가했습니다.");
return;
}
_ = SendMessageAsync(lastUserMessage);
}
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 Button CreateTaskSummaryActionButton(
string label,
string bg,
string border,
string fg,
RoutedEventHandler onClick,
bool trailingMargin = true)
{
var button = new Button
{
Content = label,
FontSize = 8.25,
MinHeight = 20,
Padding = new Thickness(6, 2, 6, 2),
Margin = trailingMargin ? new Thickness(0, 0, 3, 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, 6, 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, 6, 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("권한 건너뛰기", "#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 = 9,
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = "훅",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = hook.Success ? BrushFromHex("#334155") : BrushFromHex("#991B1B"),
}
}
});
hookCardStack.Children.Add(new TextBlock
{
Text = _appState.FormatHookEventLine(hook),
FontSize = 8.75,
TextWrapping = TextWrapping.Wrap,
Foreground = hook.Success ? secondaryText : BrushFromHex("#991B1B"),
});
var hookActionRow = new WrapPanel
{
Margin = new Thickness(0, 6, 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(7),
Padding = new Thickness(7, 4, 7, 4),
Margin = new Thickness(6, 0, 6, 3),
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 = 9,
Foreground = BrushFromHex("#1D4ED8"),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"백그라운드 {activeBackgroundCount}",
FontSize = 9,
Foreground = BrushFromHex("#1D4ED8"),
FontWeight = FontWeights.SemiBold,
}
}
});
foreach (var job in activeBackgroundJobs)
{
activeBackgroundStack.Children.Add(new TextBlock
{
Text = $"· {job.Title} · {TruncateForStatus(job.Summary, 40)}",
Margin = new Thickness(0, 2, 0, 0),
FontSize = 8.75,
Foreground = secondaryText,
TextWrapping = TextWrapping.Wrap,
});
}
return new Border
{
Background = BrushFromHex("#EFF6FF"),
BorderBrush = BrushFromHex("#BFDBFE"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(7),
Padding = new Thickness(7, 4, 7, 4),
Margin = new Thickness(6, 0, 6, 4),
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 = 9,
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
Margin = new Thickness(0, 0, 4, 0),
VerticalAlignment = VerticalAlignment.Center,
},
new TextBlock
{
Text = $"{job.Title} · {GetTaskStatusLabel(job.Status)}",
FontSize = 9,
FontWeight = FontWeights.SemiBold,
Foreground = isFailed ? BrushFromHex("#991B1B") : BrushFromHex("#334155"),
}
}
});
jobCardStack.Children.Add(new TextBlock
{
Text = $"{job.UpdatedAt:HH:mm:ss} · {TruncateForStatus(job.Summary, 48)}",
FontSize = 8.75,
TextWrapping = TextWrapping.Wrap,
Foreground = isFailed
? BrushFromHex("#991B1B")
: secondaryText,
});
if (string.Equals(job.Status, "failed", StringComparison.OrdinalIgnoreCase) && CanRetryCurrentConversation())
{
var retryBackgroundButton = CreateTaskSummaryActionButton(
"다시 시도",
"#FEF2F2",
"#FCA5A5",
"#991B1B",
(_, _) =>
{
_taskSummaryPopup?.SetCurrentValue(Popup.IsOpenProperty, false);
RetryLastUserMessageFromConversation();
},
trailingMargin: false);
retryBackgroundButton.Margin = new Thickness(0, 5, 0, 0);
jobCardStack.Children.Add(retryBackgroundButton);
}
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(7),
Padding = new Thickness(7, 4, 7, 4),
Margin = new Thickness(6, 0, 6, 3),
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(9, 7, 9, 7),
Margin = new Thickness(8, 0, 8, 6),
Child = content,
});
}
private void AddTaskSummaryPermissionHistorySection(StackPanel panel)
{
var recentPermissions = _appState.GetRecentPermissionEvents(2);
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(9, 6, 9, 6),
Margin = new Thickness(8, 0, 8, 5),
Child = card,
});
}
}
private void AddTaskSummaryHookSection(StackPanel panel)
{
var recentHooks = _appState.GetRecentHookEvents(3);
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(2);
var activeBackgroundCount = _appState.GetBackgroundJobSummary().ActiveCount;
if (activeBackgroundCount > 0)
panel.Children.Add(BuildActiveBackgroundSummaryCard(activeBackgroundJobs, activeBackgroundCount));
}
private void AddTaskSummaryObservabilitySections(StackPanel panel, ChatConversation? currentConversation)
{
if (!string.Equals(_settings.Settings.Llm.AgentLogLevel, "debug", StringComparison.OrdinalIgnoreCase))
return;
AddTaskSummaryPermissionSection(panel, currentConversation);
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);
}
}