From 890c8ce76bb54ff16ac2969fbb5c93186ae3ce50 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 5 Apr 2026 12:07:47 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EC=B1=84=ED=8C=85=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EC=B5=9C=EC=A2=85=20=EC=9D=91=EB=8B=B5=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=ED=98=95=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EC=83=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claw-code 기준으로 AX Agent 채팅 전송 흐름을 준비, 실행, 최종 assistant 커밋, 재렌더 순서로 다시 정리했습니다. ChatWindow의 SendMessageAsync와 SendRegenerateAsync에서 임시 assistant 메시지와 임시 스트리밍 컨테이너를 먼저 만드는 경로를 제거하고, 실행이 끝난 뒤 최종 assistant 텍스트만 conversation/session에 커밋하도록 수정했습니다. OnAgentEvent는 실행 로그 배너를 즉시 UI에 직접 꽂지 않고 conversation ExecutionEvents에 먼저 저장한 뒤 ShowExecutionHistory가 켜진 경우에만 RenderMessages 기반으로 다시 그리게 바꿔 Cowork/Code의 플래시 잔상과 중복 표시를 줄였습니다. AxAgentExecutionEngine도 Chat 실행을 스트리밍 UI 의존이 없는 최종 응답 커밋형 모드로 정리했습니다. 검증은 dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 기준 경고 0개, 오류 0개입니다. --- README.md | 5 + docs/DEVELOPMENT.md | 5 + .../Services/Agent/AxAgentExecutionEngine.cs | 128 + src/AxCopilot/Views/ChatWindow.xaml.cs | 2677 ++++++++++++++--- 4 files changed, 2454 insertions(+), 361 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/AxAgentExecutionEngine.cs 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(); }