diff --git a/README.md b/README.md index efab616..878d88b 100644 --- a/README.md +++ b/README.md @@ -726,6 +726,11 @@ ow + toggle 시각 언어로 통일했습니다. - 런처 보조 기능/설정 연결을 `Agent Compare` 기준으로 다시 대조하면서, 입력을 비웠을 때 이전 선택 항목과 미리보기 패널이 남아 있던 상태를 정리했습니다. [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 에서 빈 입력 시 `SelectedItem`과 미리보기 바인딩이 같이 초기화되도록 맞춰, 빠른 실행 칩/검색 히스토리/미리보기 패널 전환이 더 이상 이전 검색 상태를 끌고 가지 않게 했습니다. - 검증: `Agent Compare`의 런처 설정 항목(`ShowNumberBadges`, `CloseOnFocusLost`, `RememberPosition`, `EnableActionMode`, `EnableRandomPlaceholder`, `ShowLauncherBorder` 등)과 현재 [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs), [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 연결을 재검토했고, 런처 테마 동일화 작업은 제외한 상태에서 보조 기능/설정 연결 위주로 1차 마무리했습니다. - 업데이트: 2026-04-05 11:56 (KST) +- AX Agent 채팅 엔진 정상화 1차로, [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs) 와 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) 의 전송 흐름을 `준비 → 실행 → 최종 assistant 커밋 → 재렌더` 중심으로 다시 정리했습니다. Chat/Cowork/Code 공통으로 임시 assistant 카드와 임시 스트리밍 컨테이너를 먼저 만들지 않도록 바꿔, 토큰은 올라가는데 채팅 본문이 비거나 빈 버블이 남는 증상을 줄였습니다. +- 같은 수정에서 에이전트 실행 로그도 화면에 즉시 배너를 직접 꽂지 않고, 대화 모델의 `ExecutionEvents`에 먼저 쌓은 뒤 `RenderMessages()` 기준으로만 다시 그리게 바꿨습니다. 그래서 Cowork/Code에서 실행 로그 문구가 플래시처럼 잔상으로 남거나 중복 표시되던 흐름을 줄이는 쪽으로 정리했습니다. +- 재생성 경로도 동일하게 정리해서, 피드백 후 재생성 시 빈 assistant 메시지를 먼저 추가하지 않고 최종 응답만 커밋하도록 맞췄습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 +- 업데이트: 2026-04-05 12:06 (KST) --- diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a57b010..b7e5780 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4475,3 +4475,8 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - 업데이트: 2026-04-05 11:56 (KST) - `Agent Compare` 기준으로 런처 보조 기능/설정 연결을 다시 대조하면서, 입력을 지웠을 때 이전 선택 항목과 미리보기 상태가 남아 빠른 실행 칩·검색 히스토리·미리보기 패널 전환이 어색해지던 흐름을 정리했습니다. [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs) 는 빈 입력과 런처 재오픈 시 `SelectedItem`을 함께 초기화해, 현재 쿼리와 무관한 미리보기/선택 상태가 잔류하지 않게 했습니다. - 설정 연결 검증도 같이 진행했습니다. `Agent Compare`의 런처 설정 항목(`ShowNumberBadges`, `CloseOnFocusLost`, `RememberPosition`, `EnableActionMode`, `EnableRandomPlaceholder`, `EnableIconAnimation`, `EnableSelectionGlow`, `ShowLauncherBorder`, `EnableFavorites`, `EnableRecent`, `ShowPrefixBadge`)을 현재 [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [LauncherWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/LauncherWindow.xaml.cs), [LauncherViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/LauncherViewModel.cs), [CommandResolver.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/CommandResolver.cs), [SnippetExpander.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/SnippetExpander.cs), [WebSearchHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/WebSearchHandler.cs), [App.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/App.xaml.cs), [ClipboardHistoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/ClipboardHistoryService.cs) 기준으로 다시 점검했고, 테마 동일화는 제외한 채 보조 기능 디테일과 설정 반영 경로 위주로 마감 기준을 맞췄습니다. +- 업데이트: 2026-04-05 12:06 (KST) +- AX Agent 채팅 엔진 정상화 1차에서 [AxAgentExecutionEngine.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs)의 Chat 실행 모드를 `최종 응답 커밋형`으로 고정하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 `SendMessageAsync()` / `SendRegenerateAsync()`가 임시 assistant 메시지, 임시 스트리밍 컨테이너, 직접 UI 버블 삽입에 의존하지 않도록 다시 정리했습니다. +- 현재 흐름은 `사용자 메시지 저장 → 전송 메시지 준비 → LLM 또는 AgentLoop 실행 → 최종 assistant 텍스트 확정 → conversation/session에 커밋 → RenderMessages()` 순서입니다. 이로써 토큰은 집계되지만 본문이 비거나, assistant 빈 버블이 남거나, 재생성 시 메시지 모델과 화면 상태가 어긋나는 증상을 줄이는 쪽으로 맞췄습니다. +- `OnAgentEvent(...)`도 직접 `AddAgentEventBanner()`를 바로 꽂는 방식을 중단하고, `AppendConversationExecutionEvent()` 후 `ShowExecutionHistory`가 켜진 경우에만 `RenderMessages(preserveViewport: true)`를 다시 타게 바꿨습니다. 이 수정은 Cowork/Code에서 실행 로그가 플래시처럼 남거나 중복 잔상으로 보이던 문제를 줄이기 위한 것입니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\` 경고 0 / 오류 0 diff --git a/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs new file mode 100644 index 0000000..0638697 --- /dev/null +++ b/src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs @@ -0,0 +1,128 @@ +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Services.Agent; + +/// +/// AX Agent execution-prep engine. +/// Inspired by the `claw-code` split between input preparation and session execution, +/// so the UI layer stops owning message assembly and final assistant commit logic. +/// +public sealed class AxAgentExecutionEngine +{ + public sealed record PreparedTurn(List Messages); + public sealed record ExecutionMode(bool UseAgentLoop, bool UseStreamingTransport, string? TaskSystemPrompt); + + public IReadOnlyList BuildPromptStack( + string? conversationSystem, + string? slashSystem, + string? taskSystem = null) + { + var prompts = new List(); + if (!string.IsNullOrWhiteSpace(conversationSystem)) + prompts.Add(conversationSystem.Trim()); + if (!string.IsNullOrWhiteSpace(slashSystem)) + prompts.Add(slashSystem.Trim()); + if (!string.IsNullOrWhiteSpace(taskSystem)) + prompts.Add(taskSystem.Trim()); + return prompts; + } + + public ExecutionMode ResolveExecutionMode( + string runTab, + bool streamingEnabled, + string resolvedService, + string? coworkSystemPrompt, + string? codeSystemPrompt) + { + if (string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)) + return new ExecutionMode(true, false, coworkSystemPrompt); + + if (string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase)) + return new ExecutionMode(true, false, codeSystemPrompt); + + return new ExecutionMode(false, false, null); + } + + public PreparedTurn PrepareTurn( + ChatConversation conversation, + IEnumerable systemPrompts, + string? fileContext = null, + IReadOnlyList? images = null) + { + var outbound = conversation.Messages + .Select(CloneMessage) + .ToList(); + + if (!string.IsNullOrWhiteSpace(fileContext)) + { + var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); + if (lastUserIndex >= 0) + outbound[lastUserIndex].Content = (outbound[lastUserIndex].Content ?? string.Empty) + fileContext; + } + + if (images is { Count: > 0 }) + { + var lastUserIndex = outbound.FindLastIndex(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)); + if (lastUserIndex >= 0) + outbound[lastUserIndex].Images = images.Select(CloneImage).ToList(); + } + + var promptList = systemPrompts + .Where(prompt => !string.IsNullOrWhiteSpace(prompt)) + .Select(prompt => prompt!.Trim()) + .ToList(); + + for (var i = promptList.Count - 1; i >= 0; i--) + outbound.Insert(0, new ChatMessage { Role = "system", Content = promptList[i] }); + + return new PreparedTurn(outbound); + } + + public ChatMessage CommitAssistantMessage( + ChatSessionStateService? session, + ChatConversation conversation, + string tab, + string content, + ChatStorageService? storage = null) + { + var assistant = new ChatMessage + { + Role = "assistant", + Content = content, + }; + + if (session != null) + { + session.AppendMessage(tab, assistant, storage); + return assistant; + } + + conversation.Messages.Add(assistant); + conversation.UpdatedAt = DateTime.Now; + return assistant; + } + + private static ChatMessage CloneMessage(ChatMessage source) + { + return new ChatMessage + { + Role = source.Role, + Content = source.Content, + Timestamp = source.Timestamp, + MetaRunId = source.MetaRunId, + AttachedFiles = source.AttachedFiles?.ToList(), + Images = source.Images?.Select(CloneImage).ToList(), + }; + } + + private static ImageAttachment CloneImage(ImageAttachment source) + { + return new ImageAttachment + { + Base64 = source.Base64, + MimeType = source.MimeType, + FileName = source.FileName, + }; + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 694ec8f..15434ea 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -17,12 +17,14 @@ namespace AxCopilot.Views; 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(); @@ -45,6 +47,7 @@ public partial class ChatWindow : Window ["Cowork"] = true, ["Code"] = true, }; + private readonly Dictionary _overlaySectionExpandedStates = new(StringComparer.OrdinalIgnoreCase); private bool _failedOnlyFilter; private bool _runningOnlyFilter; private int _failedConversationCount; @@ -78,6 +81,8 @@ public partial class ChatWindow : Window // 타이핑 효과 private readonly DispatcherTimer _typingTimer; private readonly DispatcherTimer _gitRefreshTimer; + private readonly DispatcherTimer _conversationSearchTimer; + private readonly DispatcherTimer _inputUiRefreshTimer; private CancellationTokenSource? _gitStatusRefreshCts; private int _displayedLength; // 현재 화면에 표시된 글자 수 private ResourceDictionary? _agentThemeDictionary; @@ -156,6 +161,7 @@ public partial class ChatWindow : Window _llm = new LlmService(settings); _router = new ModelRouterService(settings); _toolRegistry = ToolRegistry.CreateDefault(); + _chatEngine = new AxAgentExecutionEngine(); _agentLoop = new AgentLoopService(_llm, _toolRegistry, settings) { Dispatcher = action => @@ -214,6 +220,19 @@ public partial class ChatWindow : Window _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(); + }; KeyDown += ChatWindow_KeyDown; UpdateConversationFailureFilterUi(); @@ -233,6 +252,8 @@ public partial class ChatWindow : Window ApplyExpressionLevelUi(); UpdateSidebarModeMenu(); RefreshContextUsageVisual(); + UpdateTopicPresetScrollMode(); + UpdateInputBoxHeight(); InputBox.Focus(); MessageScroll.ScrollChanged += MessageScroll_ScrollChanged; @@ -313,6 +334,7 @@ public partial class ChatWindow : Window ApplyHoverScaleAnimation(BtnSend, 1.12); ApplyHoverScaleAnimation(BtnStop, 1.12); }; + SizeChanged += (_, _) => UpdateTopicPresetScrollMode(); Closed += (_, _) => { _settings.SettingsChanged -= Settings_SettingsChanged; @@ -321,6 +343,8 @@ public partial class ChatWindow : Window _cursorTimer.Stop(); _elapsedTimer.Stop(); _typingTimer.Stop(); + _conversationSearchTimer.Stop(); + _inputUiRefreshTimer.Stop(); _llm.Dispose(); }; } @@ -691,7 +715,8 @@ public partial class ChatWindow : Window // 전체 보기 var allLabel = _activeTab switch { - "Cowork" or "Code" => "모든 프로젝트", + "Cowork" => "모든 작업 유형", + "Code" => "모든 프로젝트", _ => "모든 주제", }; stack.Children.Add(CreateCatItem("\uE8BD", allLabel, secondaryText, @@ -700,9 +725,32 @@ public partial class ChatWindow : Window stack.Children.Add(CreateSep()); - if (_activeTab == "Cowork" || _activeTab == "Code") + 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() ?? "") @@ -756,15 +804,27 @@ public partial class ChatWindow : Window { if (string.IsNullOrEmpty(_selectedCategory)) { - CategoryLabel.Text = _activeTab switch { "Cowork" or "Code" => "모든 프로젝트", _ => "주제 선택" }; + CategoryLabel.Text = _activeTab switch + { + "Cowork" => "모든 작업 유형", + "Code" => "모든 프로젝트", + _ => "주제 선택", + }; CategoryIcon.Text = "\uE8BD"; } - else if (_activeTab == "Cowork" || _activeTab == "Code") + 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 = "커스텀 프리셋"; @@ -1181,10 +1241,11 @@ public partial class ChatWindow : Window InputBox.FontSize = level == "simple" ? 13 : 14; InputBox.MaxHeight = level switch { - "rich" => 220, - "simple" => 120, - _ => 160, + "rich" => 120, + "simple" => 96, + _ => 108, }; + UpdateInputBoxHeight(); } if (InputWatermark != null) @@ -1765,14 +1826,28 @@ public partial class ChatWindow : Window if (conv == null) return; try { - conv.Permission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); - conv.DataUsage = _folderDataUsage; - conv.Mood = _selectedMood; + var normalizedPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + var dataUsage = _folderDataUsage ?? "none"; + 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, _settings.Settings.Llm.FilePermission, _folderDataUsage, conv.OutputFormat, _selectedMood, _storage); + _currentConversation = session.SaveConversationSettings(_activeTab, normalizedPermission, dataUsage, outputFormat, mood, _storage); } else { @@ -2515,8 +2590,8 @@ public partial class ChatWindow : Window if (BtnDataUsage != null) { BtnDataUsage.Background = Brushes.Transparent; - BtnDataUsage.BorderBrush = BrushFromHex($"{color}66"); - BtnDataUsage.BorderThickness = new Thickness(1); + BtnDataUsage.BorderBrush = Brushes.Transparent; + BtnDataUsage.BorderThickness = new Thickness(0); BtnDataUsage.ToolTip = _folderDataUsage switch { "none" => "폴더 문서와 파일을 읽지 않거나 참조하지 않습니다.", @@ -2757,7 +2832,6 @@ public partial class ChatWindow : Window IconBarColumn.Width = new GridLength(0); IconBarPanel.Visibility = Visibility.Collapsed; SidebarPanel.Visibility = Visibility.Visible; - ToggleSidebarIcon.Text = "\uE76B"; if (animated) { @@ -2772,7 +2846,6 @@ public partial class ChatWindow : Window } SidebarColumn.MinWidth = 0; - ToggleSidebarIcon.Text = "\uE76C"; if (animated) { AnimateSidebar(270, 0, () => @@ -2888,7 +2961,14 @@ public partial class ChatWindow : Window UpdateConversationQuickStripUi(); // 상단 필터 적용 - if (_activeTab == "Cowork" || _activeTab == "Code") + if (_activeTab == "Cowork") + { + if (!string.IsNullOrEmpty(_selectedCategory)) + { + items = items.Where(i => string.Equals(i.Category, _selectedCategory, StringComparison.OrdinalIgnoreCase)).ToList(); + } + } + else if (_activeTab == "Code") { if (!string.IsNullOrEmpty(_selectedCategory)) { @@ -3895,7 +3975,8 @@ public partial class ChatWindow : Window private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) { - RefreshConversationList(); + _conversationSearchTimer.Stop(); + _conversationSearchTimer.Start(); } private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { } @@ -4018,7 +4099,17 @@ public partial class ChatWindow : Window lock (_convLock) conv = _currentConversation; _appState.RestoreAgentRunHistory(conv?.AgentRunHistory); - var visibleMessages = conv?.Messages?.Where(msg => msg.Role != "system").ToList() ?? new List(); + var visibleMessages = conv?.Messages?.Where(msg => + { + if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase)) + return false; + + if (string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase) + && string.IsNullOrWhiteSpace(msg.Content)) + return false; + + return true; + }).ToList() ?? new List(); var visibleEvents = (conv?.ShowExecutionHistory ?? true) ? conv?.ExecutionEvents?.ToList() ?? new List() : new List(); @@ -5573,7 +5664,8 @@ public partial class ChatWindow : Window private void InputBox_TextChanged(object sender, TextChangedEventArgs e) { UpdateWatermarkVisibility(); - RefreshContextUsageVisual(); + UpdateInputBoxHeight(); + ScheduleInputUiRefresh(); var text = InputBox.Text; // 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제 @@ -5606,12 +5698,87 @@ public partial class ChatWindow : Window _slashPalette.SelectedIndex = GetFirstVisibleSlashIndex(matches); RenderSlashPage(); SlashPopup.IsOpen = true; - RefreshDraftQueueUi(); + ScheduleInputUiRefresh(); return; } } SlashPopup.IsOpen = false; - RefreshDraftQueueUi(); + ScheduleInputUiRefresh(); + } + + private void UpdateInputBoxHeight() + { + if (InputBox == null) + return; + + var text = InputBox.Text ?? string.Empty; + var explicitLineCount = 1 + text.Count(ch => ch == '\n'); + var displayMode = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); + if (displayMode is not ("rich" or "balanced" or "simple")) + displayMode = "balanced"; + + var maxLines = displayMode switch + { + "rich" => 6, + "simple" => 4, + _ => 5, + }; + + InputBox.MinLines = 1; + InputBox.MaxLines = maxLines; + InputBox.SetCurrentValue(TextBox.HeightProperty, double.NaN); + InputBox.VerticalScrollBarVisibility = explicitLineCount > maxLines + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Disabled; + } + + private static string BuildAssistantFallbackContent(ChatConversation conv, string runTab, string? content) + { + if (!string.IsNullOrWhiteSpace(content)) + return content; + + var latestEventSummary = conv.ExecutionEvents? + .Where(evt => !string.IsNullOrWhiteSpace(evt.Summary)) + .OrderByDescending(evt => evt.Timestamp) + .Select(evt => evt.Summary.Trim()) + .FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(latestEventSummary)) + { + return runTab switch + { + "Cowork" => $"코워크 작업이 완료되었습니다.\n\n{latestEventSummary}", + "Code" => $"코드 작업이 완료되었습니다.\n\n{latestEventSummary}", + _ => latestEventSummary, + }; + } + + return "(빈 응답)"; + } + + 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) @@ -6673,6 +6840,8 @@ public partial class ChatWindow : Window 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 }; @@ -8176,9 +8345,10 @@ public partial class ChatWindow : Window TryPersistConversation(force: true); UpdateChatTitle(); - AddMessageBubble("user", text); InputBox.Text = ""; + UpdateInputBoxHeight(); EmptyState.Visibility = Visibility.Collapsed; + RenderMessages(preserveViewport: true); // 대화 통계 기록 Services.UsageStatisticsService.RecordChat(runTab); @@ -8195,36 +8365,12 @@ public partial class ChatWindow : Window BtnPause.Visibility = Visibility.Visible; _streamCts = new CancellationTokenSource(); - var assistantMsg = new ChatMessage { Role = "assistant", Content = "" }; - lock (_convLock) - { - var session = ChatSession; - if (session != null) - { - session.AppendMessage(runTab, assistantMsg); - _currentConversation = session.CurrentConversation; - conv = _currentConversation!; - } - else - { - conv.Messages.Add(assistantMsg); - } - } - TryPersistConversation(force: true); - - // 어시스턴트 스트리밍 컨테이너 - var streamContainer = CreateStreamingContainer(out var streamText); - MessagePanel.Children.Add(streamContainer); - ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 - - var sb = new System.Text.StringBuilder(); - _activeStreamText = streamText; + var assistantContent = string.Empty; + _activeStreamText = null; _cachedStreamContent = ""; _displayedLength = 0; _cursorVisible = true; _aiIconPulseStopped = false; - _cursorTimer.Start(); - _typingTimer.Start(); _streamStartTime = DateTime.UtcNow; _elapsedTimer.Start(); SetStatus("응답 생성 중...", spinning: true); @@ -8235,50 +8381,54 @@ public partial class ChatWindow : Window try { List sendMessages; - lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList(); - - // 시스템 명령어가 있으면 삽입 - if (!string.IsNullOrEmpty(conv.SystemCommand)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); - - // 슬래시 명령어 시스템 프롬프트 삽입 - if (!string.IsNullOrEmpty(slashSystem)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = slashSystem }); // 첨부 파일 컨텍스트 삽입 + string? fileContext = null; if (_attachedFiles.Count > 0) { - var fileContext = BuildFileContextPrompt(); + fileContext = BuildFileContextPrompt(); if (!string.IsNullOrEmpty(fileContext)) { - var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user"); - if (lastUserIdx >= 0) - sendMessages[lastUserIdx] = new ChatMessage { Role = "user", Content = sendMessages[lastUserIdx].Content + fileContext }; + // 전송용 메시지에는 엔진이 적용하고, 원본 대화에는 첨부 기록만 남김 } - // 첨부 파일 목록 기록 후 항상 정리 (파일 읽기 실패해도) userMsg.AttachedFiles = _attachedFiles.ToList(); _attachedFiles.Clear(); RefreshAttachedFilesUI(); } // ── 이미지 첨부 ── + List? outboundImages = null; if (_pendingImages.Count > 0) { userMsg.Images = _pendingImages.ToList(); - // 마지막 사용자 메시지에 이미지 데이터 연결 - var lastUserIdx = sendMessages.FindLastIndex(m => m.Role == "user"); - if (lastUserIdx >= 0) - sendMessages[lastUserIdx] = new ChatMessage - { - Role = "user", - Content = sendMessages[lastUserIdx].Content, - Images = _pendingImages.ToList(), - }; + outboundImages = _pendingImages.ToList(); _pendingImages.Clear(); AttachedFilesPanel.Items.Clear(); if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed; } + var coworkSystem = string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase) + ? BuildCoworkSystemPrompt() + : null; + var codeSystem = string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase) + ? BuildCodeSystemPrompt() + : null; + var (resolvedService, _) = _llm.GetCurrentModelInfo(); + var executionMode = _chatEngine.ResolveExecutionMode( + runTab, + _settings.Settings.Llm.Streaming, + resolvedService, + coworkSystem, + codeSystem); + var promptStack = _chatEngine.BuildPromptStack( + conv.SystemCommand, + slashSystem, + executionMode.TaskSystemPrompt); + + sendMessages = _chatEngine + .PrepareTurn(conv, promptStack, fileContext, outboundImages) + .Messages; + // ── 전송 전 컨텍스트 사전 압축 ── { var llm = _settings.Settings.Llm; @@ -8309,124 +8459,33 @@ public partial class ChatWindow : Window } } - if (runTab == "Cowork") + if (executionMode.UseAgentLoop) { - // 워크플로우 분석기 자동 열기 - OpenWorkflowAnalyzerIfEnabled(); - - // 누적 토큰 초기화 - _agentCumulativeInputTokens = 0; - _agentCumulativeOutputTokens = 0; - - // 코워크 탭: 에이전트 루프 사용 - _agentLoop.EventOccurred += OnAgentEvent; - // 사용자 의사결정 콜백 — PlanViewerWindow로 계획 표시 - _agentLoop.UserDecisionCallback = CreatePlanDecisionCallback(); - try - { - // 코워크 시스템 프롬프트 삽입 - var coworkSystem = BuildCoworkSystemPrompt(); - if (!string.IsNullOrEmpty(coworkSystem)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = coworkSystem }); - - _agentLoop.ActiveTab = runTab; - var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token); - sb.Append(response); - assistantMsg.Content = response; - StopAiIconPulse(); - _cachedStreamContent = response; - TryPersistConversation(force: true); - - // 완료 알림 - if (_settings.Settings.Llm.NotifyOnComplete) - Services.NotificationService.Notify("AX Cowork Agent", "코워크 작업이 완료되었습니다."); - } - finally - { - _agentLoop.EventOccurred -= OnAgentEvent; - _agentLoop.UserDecisionCallback = null; - } - } - else if (runTab == "Code") - { - // 워크플로우 분석기 자동 열기 - OpenWorkflowAnalyzerIfEnabled(); - - // 누적 토큰 초기화 - _agentCumulativeInputTokens = 0; - _agentCumulativeOutputTokens = 0; - - // Code 탭: 에이전트 루프 사용 (Cowork과 동일 패턴) - _agentLoop.EventOccurred += OnAgentEvent; - _agentLoop.UserDecisionCallback = CreatePlanDecisionCallback(); - try - { - var codeSystem = BuildCodeSystemPrompt(); - if (!string.IsNullOrEmpty(codeSystem)) - sendMessages.Insert(0, new ChatMessage { Role = "system", Content = codeSystem }); - - _agentLoop.ActiveTab = runTab; - var response = await _agentLoop.RunAsync(sendMessages, _streamCts!.Token); - sb.Append(response); - assistantMsg.Content = response; - StopAiIconPulse(); - _cachedStreamContent = response; - TryPersistConversation(force: true); - - // 완료 알림 - if (_settings.Settings.Llm.NotifyOnComplete) - Services.NotificationService.Notify("AX Code Agent", "코드 작업이 완료되었습니다."); - } - finally - { - _agentLoop.EventOccurred -= OnAgentEvent; - _agentLoop.UserDecisionCallback = null; - } - } - else if (_settings.Settings.Llm.Streaming) - { - await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token)) - { - sb.Append(chunk); - StopAiIconPulse(); - _cachedStreamContent = sb.ToString(); - assistantMsg.Content = _cachedStreamContent; - TryPersistConversation(); - // UI 스레드에 제어 양보 — DispatcherTimer가 화면 갱신할 수 있도록 - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } - _cachedStreamContent = sb.ToString(); - assistantMsg.Content = _cachedStreamContent; - - // 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms) - var drainStart = DateTime.UtcNow; - while (_displayedLength < _cachedStreamContent.Length - && (DateTime.UtcNow - drainStart).TotalMilliseconds < 600) - { - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } + var response = await RunAgentLoopAsync(runTab, originTab, conv, sendMessages, _streamCts!.Token); + assistantContent = response; + StopAiIconPulse(); + _cachedStreamContent = response; } else { var response = await _llm.SendAsync(sendMessages, _streamCts.Token); - sb.Append(response); - assistantMsg.Content = response; + assistantContent = response; + StopAiIconPulse(); + _cachedStreamContent = response; } draftSucceeded = true; } catch (OperationCanceledException) { - if (sb.Length == 0) sb.Append("(취소됨)"); - assistantMsg.Content = sb.ToString(); + assistantContent = string.IsNullOrWhiteSpace(assistantContent) ? "(취소됨)" : assistantContent; draftCancelled = true; draftFailure = "사용자가 작업을 중단했습니다."; } catch (Exception ex) { var errMsg = $"⚠ 오류: {ex.Message}"; - sb.Clear(); sb.Append(errMsg); - assistantMsg.Content = errMsg; + assistantContent = errMsg; AddRetryButton(); draftFailure = ex.Message; } @@ -8457,8 +8516,21 @@ public partial class ChatWindow : Window SetStatusIdle(); } - // 스트리밍 plaintext → 마크다운 렌더링으로 교체 - FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); + assistantContent = BuildAssistantFallbackContent(conv, runTab, assistantContent); + if (runTab is "Cowork" or "Code") + conv.ShowExecutionHistory = false; + + lock (_convLock) + { + var session = ChatSession; + _chatEngine.CommitAssistantMessage(session, conv, runTab, assistantContent, _storage); + _currentConversation = session?.CurrentConversation ?? conv; + conv = _currentConversation!; + } + + lock (_convLock) + _currentConversation = conv; + RenderMessages(preserveViewport: true); AutoScrollIfNeeded(); try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } @@ -8490,6 +8562,54 @@ public partial class ChatWindow : Window _ = Dispatcher.BeginInvoke(new Action(() => StartNextQueuedDraftIfAny()), DispatcherPriority.Background); } + private async Task RunAgentLoopAsync( + string runTab, + string originTab, + ChatConversation conversation, + IReadOnlyList 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); + try + { + _storage.Save(conversation); + ChatSession?.RememberConversation(originTab, conversation.Id); + } + catch (Exception ex) + { + Services.LogService.Debug($"에이전트 루프 저장 실패: {ex.Message}"); + } + + 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 string BuildCoworkSystemPrompt() @@ -8887,10 +9007,13 @@ public partial class ChatWindow : Window { var eventTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!; - // 에이전트 이벤트를 채팅 UI에 표시 (도구 호출/결과 배너) - if (string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase)) - AddAgentEventBanner(evt); + // 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다. + // 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다. + var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false; AppendConversationExecutionEvent(evt, eventTab); + if (shouldShowExecutionHistory + && string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase)) + RenderMessages(preserveViewport: true); AutoScrollIfNeeded(); // 하단 상태바 업데이트 @@ -10862,35 +10985,14 @@ public partial class ChatWindow : Window BtnSend.Visibility = Visibility.Collapsed; BtnStop.Visibility = Visibility.Visible; _streamCts = new CancellationTokenSource(); - - var assistantMsg = new ChatMessage { Role = "assistant", Content = "" }; - lock (_convLock) - { - var session = ChatSession; - if (session != null) - { - session.AppendMessage(_activeTab, assistantMsg, _storage); - _currentConversation = session.CurrentConversation; - conv = _currentConversation!; - } - else - { - conv.Messages.Add(assistantMsg); - } - } - - var streamContainer = CreateStreamingContainer(out var streamText); - MessagePanel.Children.Add(streamContainer); ForceScrollToEnd(); // 응답 시작 시 강제 하단 이동 - var sb = new System.Text.StringBuilder(); - _activeStreamText = streamText; + var assistantContent = string.Empty; + _activeStreamText = null; _cachedStreamContent = ""; _displayedLength = 0; _cursorVisible = true; _aiIconPulseStopped = false; - _cursorTimer.Start(); - _typingTimer.Start(); _streamStartTime = DateTime.UtcNow; _elapsedTimer.Start(); SetStatus("에이전트 작업 중...", spinning: true); @@ -10898,38 +11000,23 @@ public partial class ChatWindow : Window try { List sendMessages; - lock (_convLock) sendMessages = conv.Messages.SkipLast(1).ToList(); + lock (_convLock) sendMessages = conv.Messages.ToList(); if (!string.IsNullOrEmpty(conv.SystemCommand)) sendMessages.Insert(0, new ChatMessage { Role = "system", Content = conv.SystemCommand }); - await foreach (var chunk in _llm.StreamAsync(sendMessages, _streamCts.Token)) - { - sb.Append(chunk); - StopAiIconPulse(); - _cachedStreamContent = sb.ToString(); - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } - _cachedStreamContent = sb.ToString(); - assistantMsg.Content = _cachedStreamContent; - - // 타이핑 애니메이션이 남은 버퍼를 소진할 때까지 대기 (최대 600ms) - var drainStart2 = DateTime.UtcNow; - while (_displayedLength < _cachedStreamContent.Length - && (DateTime.UtcNow - drainStart2).TotalMilliseconds < 600) - { - await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Background); - } + var response = await _llm.SendAsync(sendMessages, _streamCts.Token); + assistantContent = response; + StopAiIconPulse(); + _cachedStreamContent = response; } catch (OperationCanceledException) { - if (sb.Length == 0) sb.Append("(취소됨)"); - assistantMsg.Content = sb.ToString(); + assistantContent = string.IsNullOrWhiteSpace(assistantContent) ? "(취소됨)" : assistantContent; } catch (Exception ex) { var errMsg = $"⚠ 오류: {ex.Message}"; - sb.Clear(); sb.Append(errMsg); - assistantMsg.Content = errMsg; + assistantContent = errMsg; AddRetryButton(); } finally @@ -10951,7 +11038,15 @@ public partial class ChatWindow : Window SetStatusIdle(); } - FinalizeStreamingContainer(streamContainer, streamText, assistantMsg.Content, assistantMsg); + assistantContent = string.IsNullOrWhiteSpace(assistantContent) ? "(빈 응답)" : assistantContent; + lock (_convLock) + { + var session = ChatSession; + _chatEngine.CommitAssistantMessage(session, conv, _activeTab, assistantContent, _storage); + _currentConversation = session?.CurrentConversation ?? conv; + conv = _currentConversation!; + } + RenderMessages(preserveViewport: true); AutoScrollIfNeeded(); try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); } @@ -10960,14 +11055,18 @@ public partial class ChatWindow : Window RefreshConversationList(); } - /// 메시지 버블의 MaxWidth를 창 너비에 비례하여 계산합니다 (더 넓은 본문 레이아웃). + /// 채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다. private double GetMessageMaxWidth() { - var scrollWidth = MessageScroll.ActualWidth; - if (scrollWidth < 100) scrollWidth = 700; // 초기화 전 기본값 - // 좌우 여백을 더 줄여 본문과 composer가 넓게 보이도록 조정 - var maxW = (scrollWidth - 56) * 0.975; - return Math.Clamp(maxW, 620, 1560); + var 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, 720); } private StackPanel CreateStreamingContainer(out TextBlock streamText) @@ -11061,6 +11160,8 @@ public partial class ChatWindow : Window 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); @@ -12057,13 +12158,16 @@ public partial class ChatWindow : Window private void BuildTopicButtons() { TopicButtonPanel.Children.Clear(); - TopicButtonPanel.Visibility = Visibility.Visible; + if (TopicPresetScrollViewer != null) + TopicPresetScrollViewer.Visibility = Visibility.Visible; // 탭별 EmptyState 텍스트 if (_activeTab == "Cowork" || _activeTab == "Code") { - if (EmptyStateTitle != null) EmptyStateTitle.Text = "작업 유형을 선택하세요"; + if (EmptyStateTitle != null) EmptyStateTitle.Text = _activeTab == "Code" + ? "코드 작업을 입력하세요" + : "작업 유형을 선택하세요"; if (EmptyStateDesc != null) EmptyStateDesc.Text = _activeTab == "Code" ? "코딩 에이전트가 코드 분석, 수정, 빌드, 테스트를 수행합니다" : "에이전트가 상세한 데이터를 작성합니다"; @@ -12074,6 +12178,14 @@ public partial class ChatWindow : Window if (EmptyStateDesc != null) EmptyStateDesc.Text = "주제에 맞는 전문 프리셋이 자동 적용됩니다"; } + if (_activeTab == "Code") + { + TopicButtonPanel.Visibility = Visibility.Collapsed; + if (TopicPresetScrollViewer != null) + TopicPresetScrollViewer.Visibility = Visibility.Collapsed; + return; + } + var presets = Services.PresetService.GetByTabWithCustom(_activeTab, _settings.Settings.Llm.CustomPresets); foreach (var preset in presets) @@ -12089,7 +12201,7 @@ public partial class ChatWindow : Window Margin = new Thickness(6, 6, 6, 10), Cursor = Cursors.Hand, Width = 124, - Height = 108, + Height = 116, ClipToBounds = true, RenderTransformOrigin = new Point(0.5, 0.5), RenderTransform = new ScaleTransform(1, 1), @@ -12133,7 +12245,7 @@ public partial class ChatWindow : Window Text = preset.Description, FontSize = 9, TextWrapping = TextWrapping.Wrap, TextTrimming = TextTrimming.CharacterEllipsis, - MaxHeight = 28, + MaxHeight = 32, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 2, 0, 0), @@ -12219,7 +12331,7 @@ public partial class ChatWindow : Window Margin = new Thickness(6, 6, 6, 10), Cursor = Cursors.Hand, Width = 124, - Height = 108, + Height = 116, ClipToBounds = true, RenderTransformOrigin = new Point(0.5, 0.5), RenderTransform = new ScaleTransform(1, 1), @@ -12258,7 +12370,7 @@ public partial class ChatWindow : Window Text = "프리셋 없이 자유롭게 대화합니다", FontSize = 9, TextWrapping = TextWrapping.Wrap, TextTrimming = TextTrimming.CharacterEllipsis, - MaxHeight = 28, + MaxHeight = 32, Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 2, 0, 0), @@ -12298,7 +12410,7 @@ public partial class ChatWindow : Window Padding = new Thickness(14, 14, 14, 14), Margin = new Thickness(6, 6, 6, 10), Cursor = Cursors.Hand, - Width = 124, Height = 108, + Width = 124, Height = 116, ClipToBounds = true, BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, BorderThickness = new Thickness(1.5), @@ -12349,6 +12461,29 @@ public partial class ChatWindow : Window addBorder.MouseLeftButtonDown += (_, _) => ShowCustomPresetDialog(); TopicButtonPanel.Children.Add(addBorder); } + + UpdateTopicPresetScrollMode(); + } + + private void UpdateTopicPresetScrollMode() + { + if (TopicPresetScrollViewer == null || TopicButtonPanel == null) + return; + + Dispatcher.BeginInvoke(new Action(() => + { + if (TopicPresetScrollViewer == null || TopicButtonPanel == null) + return; + + TopicPresetScrollViewer.UpdateLayout(); + var shouldScroll = TopicPresetScrollViewer.ExtentHeight > TopicPresetScrollViewer.ViewportHeight + 1; + TopicPresetScrollViewer.VerticalScrollBarVisibility = shouldScroll + ? ScrollBarVisibility.Auto + : ScrollBarVisibility.Disabled; + TopicPresetScrollViewer.Padding = shouldScroll + ? new Thickness(0, 2, 6, 0) + : new Thickness(0, 2, 0, 0); + }), System.Windows.Threading.DispatcherPriority.Loaded); } // ─── 커스텀 프리셋 관리 ───────────────────────────────────────────── @@ -12567,59 +12702,14 @@ public partial class ChatWindow : Window private void BuildBottomBar() { MoodIconPanel.Children.Clear(); - - var workspaceBtn = CreateWorkspaceFolderBarButton(); - workspaceBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFolderMenu(); }; - MoodIconPanel.Children.Add(workspaceBtn); - - // ── 파일 탐색기 토글 버튼 ── - var fileBrowserBtn = CreateFolderBarButton("\uED25", "파일", "파일 탐색기 열기/닫기", "#D97706"); - fileBrowserBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ToggleFileBrowser(); }; - MoodIconPanel.Children.Add(fileBrowserBtn); - - // ── 실행 이력 상세도 버튼 ── - AppendLogLevelButton(); - - // 구분선 표시 - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; } /// Code 탭 하단 바: 로컬 / 브랜치 / 워크트리 흐름 중심. private void BuildCodeBottomBar() { MoodIconPanel.Children.Clear(); - - var workspaceBtn = CreateWorkspaceFolderBarButton(); - workspaceBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowFolderMenu(); }; - MoodIconPanel.Children.Add(workspaceBtn); - - var localBtn = CreateFolderBarButton("\uED25", "로컬", "원본 워크스페이스로 전환", "#6B7280"); - localBtn.MouseLeftButtonUp += (_, e) => - { - e.Handled = true; - var currentFolder = GetCurrentWorkFolder(); - if (string.IsNullOrWhiteSpace(currentFolder) || !Directory.Exists(currentFolder)) - { - ShowFolderMenu(); - return; - } - - var root = WorktreeStateStore.ResolveRoot(currentFolder); - if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root)) - { - ShowFolderMenu(); - return; - } - - SwitchToWorkspace(root, root); - }; - MoodIconPanel.Children.Add(localBtn); - - var worktreeBtn = CreateFolderBarButton("\uE8B7", "워크트리", "분리된 작업 복사본 생성 또는 전환", "#2563EB"); - worktreeBtn.MouseLeftButtonUp += (_, e) => { e.Handled = true; ShowWorktreeMenu(worktreeBtn); }; - MoodIconPanel.Children.Add(worktreeBtn); - - if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Visible; + if (FormatMoodSeparator != null) FormatMoodSeparator.Visibility = Visibility.Collapsed; } private Border CreateWorkspaceFolderBarButton() @@ -13937,6 +14027,10 @@ public partial class ChatWindow : Window 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; @@ -14200,11 +14294,11 @@ public partial class ChatWindow : Window RefreshOverlaySettingsPanel(); AgentSettingsOverlay.Visibility = Visibility.Visible; InlineSettingsPanel.IsOpen = false; - SetOverlaySection("common"); + SetOverlaySection("basic"); Dispatcher.BeginInvoke(() => { - if (OverlayNavCommon != null) - OverlayNavCommon.Focus(); + if (OverlayNavBasic != null) + OverlayNavBasic.Focus(); else InputBox.Focus(); }, DispatcherPriority.Input); @@ -14248,7 +14342,7 @@ public partial class ChatWindow : Window { var llm = _settings.Settings.Llm; - _settings.Settings.AiEnabled = ChkOverlayAiEnabled?.IsChecked == true; + _settings.Settings.AiEnabled = true; llm.EnableProactiveContextCompact = ChkOverlayEnableProactiveCompact?.IsChecked == true; llm.EnableSkillSystem = ChkOverlayEnableSkillSystem?.IsChecked == true; llm.EnableToolHooks = ChkOverlayEnableToolHooks?.IsChecked == true; @@ -14256,7 +14350,18 @@ public partial class ChatWindow : Window 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.EnablePlanModeTools = ChkOverlayEnablePlanModeTools?.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; llm.FolderDataUsage = _folderDataUsage; CommitOverlayEndpointInput(normalizeOnInvalid: true); @@ -14264,7 +14369,20 @@ public partial class ChatWindow : Window 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) @@ -14341,10 +14459,12 @@ public partial class ChatWindow : Window ? "미선택" : (models.FirstOrDefault(m => string.Equals(m.Id, llm.Model, StringComparison.OrdinalIgnoreCase)).Label ?? llm.Model); + PopulateOverlayMoodCombo(); + if (loadDeferredInputs) { if (ChkOverlayAiEnabled != null) - ChkOverlayAiEnabled.IsChecked = _settings.Settings.AiEnabled; + ChkOverlayAiEnabled.IsChecked = true; if (TxtOverlayServiceEndpoint != null) TxtOverlayServiceEndpoint.Text = GetOverlayServiceEndpoint(service); if (TxtOverlayServiceApiKey != null) @@ -14353,8 +14473,64 @@ public partial class ChatWindow : Window 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) @@ -14369,23 +14545,39 @@ public partial class ChatWindow : Window 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 (BtnOverlayDefaultOutputFormat != null) - BtnOverlayDefaultOutputFormat.Content = $"AI 자동 · {GetFormatLabel(llm.DefaultOutputFormat ?? "auto")}"; - if (BtnOverlayDefaultMood != null) - { - var mood = TemplateService.AllMoods.FirstOrDefault(m => m.Key == (_selectedMood ?? llm.DefaultMood ?? "modern")); - BtnOverlayDefaultMood.Content = mood == null ? "모던" : $"{mood.Icon} {mood.Label}"; + if (ChkOverlayEnableProjectRules != null) + ChkOverlayEnableProjectRules.IsChecked = llm.EnableProjectRules; + if (ChkOverlayEnableAgentMemory != null) + ChkOverlayEnableAgentMemory.IsChecked = llm.EnableAgentMemory; + if (ChkOverlayEnablePlanModeTools != null) + ChkOverlayEnablePlanModeTools.IsChecked = llm.Code.EnablePlanModeTools; + 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); BuildOverlayModelChips(service); + BuildOverlayRegisteredModelsPanel(service); RefreshOverlayAdvancedChoiceButtons(); } finally @@ -14508,6 +14700,9 @@ public partial class ChatWindow : Window return true; } + private LlmSettings? TryGetOverlayLlmSettings() + => _settings?.Settings?.Llm; + private void MarkOverlayValidation(Control? control, string message) { if (control == null) @@ -14558,7 +14753,7 @@ public partial class ChatWindow : Window if (_isOverlaySettingsSyncing) return; - _settings.Settings.AiEnabled = ChkOverlayAiEnabled?.IsChecked == true; + _settings.Settings.AiEnabled = true; PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); } @@ -14617,6 +14812,195 @@ public partial class ChatWindow : Window 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 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 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 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 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 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 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 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 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 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) @@ -14626,6 +15010,173 @@ public partial class ChatWindow : Window 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 OverlayNav_Checked(object sender, RoutedEventArgs e) { if (sender is not RadioButton rb || rb.Tag is not string tag) @@ -14639,56 +15190,1099 @@ public partial class ChatWindow : Window if (OverlaySectionService == null || OverlaySectionQuick == null || OverlaySectionDetail == null) return; - var section = string.IsNullOrWhiteSpace(tag) ? "common" : tag.Trim().ToLowerInvariant(); - var showCommon = section == "common"; - var showService = section == "service"; - var showPermission = section == "permission"; - var showAdvanced = section == "advanced"; + 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 showEtc = section == "etc"; - OverlaySectionService.Visibility = showCommon || showService ? Visibility.Visible : Visibility.Collapsed; - OverlaySectionQuick.Visibility = showCommon || showPermission ? Visibility.Visible : Visibility.Collapsed; + OverlaySectionService.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; + OverlaySectionQuick.Visibility = showShared || showCowork || showCode ? Visibility.Visible : Visibility.Collapsed; OverlaySectionDetail.Visibility = Visibility.Visible; + var headingTitle = section switch + { + "chat" => "채팅 설정", + "shared" => "코워크/코드 공통 설정", + "cowork" => "코워크 설정", + "code" => "코드 설정", + "dev" => "개발자 설정", + "tools" => "도구 설정", + "etc" => "스킬/차단 설정", + _ => "공통 설정" + }; + var headingDescription = section switch + { + "chat" => "Chat 탭에서 쓰는 입력/내보내기 같은 채팅 전용 설정입니다.", + "shared" => "Cowork와 Code에서 함께 쓰는 문맥/압축 관련 기본 설정입니다.", + "cowork" => "문서/업무 협업 흐름에 맞춘 코워크 전용 설정입니다.", + "code" => "코드 작업, 검증, 개발 도구 사용에 맞춘 설정입니다.", + "dev" => "실행 이력, 감사, 시각화 같은 개발자용 설정입니다.", + "tools" => "AX Agent가 사용할 도구와 훅 동작을 관리합니다.", + "etc" => "스킬 로드와 차단 규칙, 보조 연결을 관리합니다.", + _ => "Chat, Cowork, Code에서 공통으로 쓰는 기본 설정입니다." + }; + + if (OverlayTopHeadingTitle != null) + OverlayTopHeadingTitle.Text = headingTitle; + if (OverlayTopHeadingDescription != null) + OverlayTopHeadingDescription.Text = headingDescription; if (OverlayAnchorCommon != null) - OverlayAnchorCommon.Text = section switch - { - "service" => "서비스 설정", - "permission" => "권한 설정", - "advanced" => "고급 설정", - _ => "일반 설정" - }; + OverlayAnchorCommon.Text = headingTitle; if (OverlayAiEnabledRow != null) - OverlayAiEnabledRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed; + OverlayAiEnabledRow.Visibility = Visibility.Collapsed; if (OverlayThemePanel != null) - OverlayThemePanel.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed; + OverlayThemePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; if (OverlayThemeStylePanel != null) - OverlayThemeStylePanel.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed; + OverlayThemeStylePanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; if (OverlayDefaultOutputFormatRow != null) - OverlayDefaultOutputFormatRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed; + OverlayDefaultOutputFormatRow.Visibility = showCowork ? Visibility.Visible : Visibility.Collapsed; if (OverlayDefaultMoodRow != null) - OverlayDefaultMoodRow.Visibility = showCommon ? Visibility.Visible : Visibility.Collapsed; + 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 = showCommon || showService ? Visibility.Visible : Visibility.Collapsed; + OverlayModelEditorPanel.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; if (OverlayAnchorPermission != null) - OverlayAnchorPermission.Visibility = showPermission ? Visibility.Visible : Visibility.Collapsed; + OverlayAnchorPermission.Visibility = showBasic ? Visibility.Visible : Visibility.Collapsed; if (OverlayFolderDataUsageRow != null) - OverlayFolderDataUsageRow.Visibility = showCommon || showPermission ? Visibility.Visible : Visibility.Collapsed; + OverlayFolderDataUsageRow.Visibility = showShared || showCowork ? Visibility.Visible : Visibility.Collapsed; if (OverlayTlsRow != null) - OverlayTlsRow.Visibility = showCommon || showService || showPermission ? Visibility.Visible : Visibility.Collapsed; + OverlayTlsRow.Visibility = showChat ? Visibility.Visible : Visibility.Collapsed; if (OverlayAnchorAdvanced != null) - OverlayAnchorAdvanced.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed; + OverlayAnchorAdvanced.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; + if (TxtOverlayContextCompactTriggerPercent != null) + TxtOverlayContextCompactTriggerPercent.Visibility = Visibility.Collapsed; if (OverlayMaxContextTokensRow != null) - OverlayMaxContextTokensRow.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed; + OverlayMaxContextTokensRow.Visibility = showShared ? Visibility.Visible : Visibility.Collapsed; + if (OverlayTemperatureRow != null) + OverlayTemperatureRow.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; if (OverlayMaxRetryRow != null) - OverlayMaxRetryRow.Visibility = showAdvanced ? Visibility.Visible : Visibility.Collapsed; + 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 = showAdvanced ? Visibility.Visible : Visibility.Collapsed; + OverlayAdvancedTogglePanel.Visibility = showDev || showCowork || showCode || showTools || showEtc ? 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 (OverlayEtcInfoPanel != null) + OverlayEtcInfoPanel.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayEtcRuntimePanel != null) + OverlayEtcRuntimePanel.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleProactiveCompact != null) + OverlayToggleProactiveCompact.Visibility = showDev ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleSkillSystem != null) + OverlayToggleSkillSystem.Visibility = showEtc ? 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 = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayToggleAgentMemory != null) + OverlayToggleAgentMemory.Visibility = showEtc ? Visibility.Visible : Visibility.Collapsed; + if (OverlayTogglePlanModeTools != null) + OverlayTogglePlanModeTools.Visibility = showCode ? 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 || showEtc) + RefreshOverlayEtcPanels(); } private void RefreshOverlaySettingsPanel() { RefreshOverlayVisualState(loadDeferredInputs: true); + RefreshOverlayEtcPanels(); + } + + 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 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; + if (servers == null || servers.Count == 0) + { + OverlayMcpServerListPanel.Children.Add(new TextBlock + { + Text = "등록된 MCP 서버가 없습니다.", + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + return; + } + + foreach (var server in servers) + { + 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 = server.Name, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.Black + }); + stack.Children.Add(new TextBlock + { + Text = $"{server.Command} {(server.Enabled ? "· 활성" : "· 비활성")}", + Margin = new Thickness(0, 3, 0, 0), + FontSize = 10.5, + TextWrapping = TextWrapping.Wrap, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray + }); + card.Child = stack; + OverlayMcpServerListPanel.Children.Add(card); + } + } + + 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) @@ -14744,41 +16338,79 @@ public partial class ChatWindow : Window 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, + <= 65536 => 65536, + <= 262144 => 262144, + _ => 1_000_000 + }; + SetOverlayCardSelection(OverlayContext4KCard, context == 4096); + SetOverlayCardSelection(OverlayContext16KCard, context == 16384); + SetOverlayCardSelection(OverlayContext64KCard, context == 65536); + SetOverlayCardSelection(OverlayContext256KCard, context == 262144); + SetOverlayCardSelection(OverlayContext1MCard, context == 1_000_000); + } + private void RefreshOverlayModeButtons() { var llm = _settings.Settings.Llm; - var operationModeLabel = OperationModePolicy.Normalize(_settings.Settings.OperationMode) == OperationModePolicy.ExternalMode - ? "사외 모드" - : "사내 모드"; - var dataUsageLabel = _folderDataUsage switch - { - "active" => "적극 활용", - "passive" => "소극 활용", - _ => "활용하지 않음", - }; - var permissionLabel = PermissionModeCatalog.ToDisplayLabel(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission)); - var planLabel = PlanModeLabel(llm.PlanMode); - var reasoningLabel = ReasoningLabel(llm.AgentDecisionLevel); - - BtnOverlayOperationMode.Content = GetQuickActionLabel("모드", operationModeLabel); - BtnOverlayFolderDataUsage.Content = GetQuickActionLabel("데이터", dataUsageLabel); - BtnOverlayPermission.Content = GetQuickActionLabel("권한", permissionLabel); - BtnOverlayPlanMode.Content = GetQuickActionLabel("계획", planLabel); - BtnOverlayReasoning.Content = GetQuickActionLabel("추론", reasoningLabel); - if (BtnOverlayFastMode != null) - { - BtnOverlayFastMode.Content = GetQuickActionLabel("Fast", llm.FreeTierMode ? "켜짐" : "꺼짐"); - ApplyQuickActionVisual(BtnOverlayFastMode, llm.FreeTierMode, "#ECFDF5", "#166534"); - } - ApplyQuickActionVisual(BtnOverlayReasoning, !string.Equals(llm.AgentDecisionLevel, "normal", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#1D4ED8"); - ApplyQuickActionVisual(BtnOverlayPlanMode, !string.Equals(llm.PlanMode, "off", StringComparison.OrdinalIgnoreCase), "#EEF2FF", "#4338CA"); - ApplyQuickActionVisual(BtnOverlayPermission, - !string.Equals(PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission), PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase), - "#FFF7ED", - "#C2410C"); + SelectComboTag(CmbOverlayOperationMode, OperationModePolicy.Normalize(_settings.Settings.OperationMode)); + SelectComboTag(CmbOverlayFolderDataUsage, _folderDataUsage); + SelectComboTag(CmbOverlayPermission, PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission)); + SelectComboTag(CmbOverlayPlanMode, llm.PlanMode); + 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() + .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}"; @@ -14907,6 +16539,226 @@ public partial class ChatWindow : Window } } + 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, + }; + + Button CreateAction(string text, RoutedEventHandler onClick, Brush foreground) + { + var button = new Button + { + Content = text, + Style = TryFindResource("GhostBtn") as Style, + Padding = new Thickness(8, 4, 8, 4), + Margin = new Thickness(6, 0, 0, 0), + Foreground = foreground, + Cursor = Cursors.Hand, + }; + button.Click += onClick; + return button; + } + + actions.Children.Add(CreateAction("선택", (_, _) => + { + CommitOverlayModelSelection(model.EncryptedModelName); + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + }, accentBrush)); + + actions.Children.Add(CreateAction("편집", (_, _) => EditOverlayRegisteredModel(model), 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; @@ -15000,6 +16852,103 @@ public partial class ChatWindow : Window PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); } + 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 CmbOverlayPlanMode_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isOverlaySettingsSyncing || CmbOverlayPlanMode.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _settings.Settings.Llm.PlanMode = 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) + { + if (_isOverlaySettingsSyncing || CmbOverlayFolderDataUsage.SelectedItem is not ComboBoxItem selected || selected.Tag is not string tag) + return; + + _folderDataUsage = tag; + PersistOverlaySettingsState(refreshOverlayDeferredInputs: false); + } + + 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) @@ -16682,6 +18631,13 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi || TokenUsageThresholdMarker == null || CompactNowLabel == null) return; + var showContextUsage = _activeTab is "Cowork" or "Code"; + TokenUsageCard.Visibility = showContextUsage ? Visibility.Visible : Visibility.Collapsed; + if (!showContextUsage) + { + return; + } + var llm = _settings.Settings.Llm; var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000); var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95); @@ -17573,6 +19529,7 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi InputBox.Clear(); InputBox.Focus(); + UpdateInputBoxHeight(); RefreshDraftQueueUi(); if (queuedItem == null) @@ -17628,6 +19585,7 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi InputBox.Clear(); InputBox.Focus(); + UpdateInputBoxHeight(); RefreshDraftQueueUi(); } @@ -17643,16 +19601,12 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi _draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage); } - var inputText = InputBox?.Text?.Trim() ?? ""; - var hasInput = !string.IsNullOrWhiteSpace(inputText); var summary = _appState.GetDraftQueueSummary(_activeTab); var items = _appState.GetDraftQueueItems(_activeTab); - DraftPreviewCard.Visibility = hasInput ? Visibility.Visible : Visibility.Collapsed; - BtnDraftEnqueue.IsEnabled = hasInput; - DraftPreviewText.Text = hasInput - ? $"{TruncateForStatus(inputText, 96)}{(summary.TotalCount > 0 ? $" · 대기 {summary.QueuedCount} · 실행 {summary.RunningCount}" : "")}" - : ""; + DraftPreviewCard.Visibility = Visibility.Collapsed; + BtnDraftEnqueue.IsEnabled = false; + DraftPreviewText.Text = string.Empty; RebuildDraftQueuePanel(items); } @@ -18033,6 +19987,7 @@ private static (string icon, string label, string bgHex, string fgHex) GetDecisi InputBox.Text = next.Text; InputBox.CaretIndex = InputBox.Text.Length; InputBox.Focus(); + UpdateInputBoxHeight(); RefreshDraftQueueUi(); _ = SendMessageAsync(); }