From 1256fdc43f54ea3f729d4909c25637814ab8f072 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 01:06:46 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9/=EB=8F=84=EA=B5=AC=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EB=B0=8F=20=EA=B0=9C=EB=B0=9C=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A7=84=ED=96=89=20=EC=9D=B4=EB=A0=A5=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /chrome: 인자 없는 진단 모드와 실행 라우팅 분리, MCP 재연결 자동 재시도 경로 보강 - /mcp: status/enable/disable/reconnect 명령 정리 및 상태 라벨 표준화 - /settings, /permissions 하위 액션 명확화, /verify·/commit 로컬 실행 흐름 정리 - /commit files:path1,path2 :: message 형태의 부분 커밋 지원 추가 - GitTool commit 경로의 레거시 비활성 응답 제거로 정책 일관성 확보 - ChatWindowSlashPolicyTests 신규 추가 및 AgentParityToolsTests 회귀 방지 테스트 보강 - docs/DEVELOPMENT.md, docs/AGENT_ROADMAP.md에 2026-04-04 진행 기록/스냅샷 반영 --- docs/AGENT_ROADMAP.md | 10 + docs/DEVELOPMENT.md | 33 + .../Services/AgentParityToolsTests.cs | 30 + .../Views/ChatWindowSlashPolicyTests.cs | 77 + src/AxCopilot/Services/Agent/GitTool.cs | 12 +- src/AxCopilot/Views/ChatWindow.xaml.cs | 2026 ++++++++++++++++- 6 files changed, 2054 insertions(+), 134 deletions(-) create mode 100644 src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index e147a4b..6e0d354 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -38,6 +38,16 @@ - `dotnet test --filter "Suite=ReplayStability"`: 14/14 통과. - `dotnet test`: 379/379 통과. +## 7. 명령/도구 보강 스냅샷 (2026-04-04) +- 슬래시 명령 고도화: `/chrome`, `/mcp`, `/verify`, `/commit`, `/settings`, `/permissions` 하위 동작 정리. +- `/mcp` 상태 라벨 표준화: `Connected`, `NeedsAuth`, `Configured`, `Disconnected`, `Disabled`. +- `/chrome` 런타임 재시도: 초기 probe 실패 시 `/mcp reconnect all` 자동 수행 후 1회 재평가. +- Git 정책 정렬: `git_tool`의 `commit` 비활성 문구 제거(로컬 커밋 경로와 정책 일치). +- `/commit` 부분 커밋 지원: `files:path1,path2 :: 메시지` 형식으로 선택 파일만 stage+commit 가능. +- 테스트 보강: + - `ChatWindowSlashPolicyTests`: 슬래시 파서/검증 프롬프트/MCP 상태 라벨 단위 검증 추가. + - `AgentParityToolsTests`: `git_tool commit` 레거시 비활성 메시지 회귀 방지 테스트 추가. + ## 7. 권한 Hook 계약 (P2 마감 기준) - lifecycle hook 키: - `__permission_request__` (pre) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 49c7ae8..d6b5071 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2690,3 +2690,36 @@ else: | 현재 (v1.6.1) | 스킬 프롬프트에서 양식 자동 감지 안내 | 완료 | | 다음 | 시스템 프롬프트에 양식 목록 자동 주입 (BuildCoworkSystemPrompt) | 낮음 | | 장기 | `.ax/templates/` 공용 양식 폴더 + 프로젝트 규칙에서 기본 양식 지정 | 중간 | + +--- + +## 2026-04-04 진행 기록 (AX Agent 명령/도구 보강) + +### 1. 슬래시 명령 체계 보강 +- `/chrome`: 인자 미입력 시 MCP 런타임 진단, 인자 입력 시 브라우저 작업 실행 경로로 라우팅. +- `/mcp`: `status`, `enable|disable`, `reconnect` 동작 정리 및 상태 라벨 표준화. +- `/settings`: `model|permissions|mcp|theme` 하위 액션 지원. +- `/permissions`: `ask|auto|deny|status` 하위 액션 지원. +- `/verify`: Cowork/Code 탭 전용 검증 프롬프트 경로로 고정. +- `/commit`: 승인 기반 커밋 실행 + `files:path1,path2 :: message` 부분 커밋 지원. +- `/compact`: 수동 컨텍스트 압축 명령 유지 및 slash 팝업 힌트/도움말 반영. + +### 2. 도구/권한 정책 정렬 +- `GitTool`의 commit 경로에서 레거시 비활성 응답(`커밋 비활성`) 제거. +- 세션 단위 MCP 활성/비활성 오버라이드 적용(영구 설정 미오염). +- `/chrome` 진단 실패 시 `/mcp reconnect all` 자동 1회 재시도 후 상태 재평가. + +### 3. 테스트 보강 +- `ChatWindowSlashPolicyTests`: + - 슬래시 파서(`ParseGenericAction`, `ParseMcpAction`) 검증. + - `/verify` 시스템 프롬프트 필수 섹션 검증. + - MCP 상태 라벨 매핑 검증. + - `/commit` 입력 파서 검증. +- `AgentParityToolsTests`: + - `git_tool commit` 레거시 비활성 메시지 회귀 방지 테스트 추가. + +### 4. 품질 게이트 결과 +- `dotnet build AxCopilot.sln` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과. +- 기준 시점 전체 테스트: 403 passed, 0 failed. + diff --git a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs index 02a164a..3f3e734 100644 --- a/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs +++ b/src/AxCopilot.Tests/Services/AgentParityToolsTests.cs @@ -200,4 +200,34 @@ public class AgentParityToolsTests foreach (var name in required) names.Should().Contain(name); } + + [Fact] + public async Task GitTool_Commit_ShouldNotReturnLegacyDisabledMessage() + { + var workDir = Path.Combine(Path.GetTempPath(), "ax-git-commit-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + Directory.CreateDirectory(Path.Combine(workDir, ".git")); + try + { + var context = new AgentContext + { + WorkFolder = workDir, + Permission = "Auto", + OperationMode = "external", + }; + + var tool = new GitTool(); + var result = await tool.ExecuteAsync( + JsonDocument.Parse("""{"action":"commit","args":"테스트 커밋 메시지"}""").RootElement, + context, + CancellationToken.None); + + result.Output.Should().NotContain("비활성 상태"); + result.Output.Should().NotContain("향후 버전에서 활성화"); + } + finally + { + try { if (Directory.Exists(workDir)) Directory.Delete(workDir, true); } catch { } + } + } } diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs new file mode 100644 index 0000000..26c0d0a --- /dev/null +++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs @@ -0,0 +1,77 @@ +using AxCopilot.Views; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Views; + +public class ChatWindowSlashPolicyTests +{ + [Fact] + public void BuildVerifySystemPrompt_ShouldContainStructuredSections() + { + var prompt = ChatWindow.BuildVerifySystemPrompt("/verify"); + + prompt.Should().Contain("[검증결과]"); + prompt.Should().Contain("[실행요약]"); + prompt.Should().Contain("[변경파일]"); + prompt.Should().Contain("[잔여리스크]"); + } + + [Theory] + [InlineData("/mcp", "status", "")] + [InlineData("enable all", "enable", "all")] + [InlineData("reconnect chrome", "reconnect", "chrome")] + [InlineData("status", "status", "")] + public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget) + { + var (action, target) = ChatWindow.ParseMcpAction(displayText); + + action.Should().Be(expectedAction); + target.Should().Be(expectedTarget); + } + + [Theory] + [InlineData("/settings", "open", "")] + [InlineData("model", "model", "")] + [InlineData("ask", "ask", "")] + [InlineData("reconnect all", "reconnect", "all")] + public void ParseGenericAction_ShouldParseExpected(string displayText, string expectedAction, string expectedArgument) + { + var (action, argument) = ChatWindow.ParseGenericAction(displayText, "/settings"); + + action.Should().Be(expectedAction); + argument.Should().Be(expectedArgument); + } + + [Theory] + [InlineData(false, "stdio", true, false, null, "Disabled")] + [InlineData(true, "stdio", false, false, null, "Enabled")] + [InlineData(true, "sse", true, false, null, "Configured")] + [InlineData(true, "stdio", true, false, null, "Disconnected")] + [InlineData(true, "stdio", true, true, 0, "NeedsAuth (도구 0개)")] + [InlineData(true, "stdio", true, true, 3, "Connected (도구 3개)")] + public void ResolveMcpDisplayStatus_ShouldReturnNormalizedLabel( + bool enabled, + string transport, + bool runtimeCheck, + bool connected, + int? toolCount, + string expected) + { + var label = ChatWindow.ResolveMcpDisplayStatus(enabled, transport, runtimeCheck, connected, toolCount); + label.Should().Be(expected); + } + + [Theory] + [InlineData("/commit", 0, "")] + [InlineData("feat: 로그인 오류 수정", 0, "feat: 로그인 오류 수정")] + [InlineData("files:src/A.cs, src/B.cs :: feat: 부분 커밋", 2, "feat: 부분 커밋")] + [InlineData("files:src/A.cs,src/A.cs", 1, "")] + public void ParseCommitCommandInput_ShouldParseExpected(string input, int expectedFileCount, string expectedMessage) + { + var (files, message) = ChatWindow.ParseCommitCommandInput(input); + + files.Count.Should().Be(expectedFileCount); + message.Should().Be(expectedMessage); + } +} diff --git a/src/AxCopilot/Services/Agent/GitTool.cs b/src/AxCopilot/Services/Agent/GitTool.cs index 40cd6d8..530f6f3 100644 --- a/src/AxCopilot/Services/Agent/GitTool.cs +++ b/src/AxCopilot/Services/Agent/GitTool.cs @@ -8,7 +8,7 @@ namespace AxCopilot.Services.Agent; /// /// Git 버전 관리 도구. /// 사내 GitHub Enterprise 환경을 고려하여 안전한 Git 작업을 지원합니다. -/// push/force push는 차단되며, 사용자가 직접 수행해야 합니다. + /// push/force push는 차단되며, 사용자가 직접 수행해야 합니다. /// public class GitTool : IAgentTool { @@ -126,16 +126,6 @@ public class GitTool : IAgentTool return ToolResult.Fail("Git 쓰기 권한이 거부되었습니다."); } - // Git 커밋 — 현재 비활성 (향후 활성화 예정) - // 의사결정 수준에서 무조건 확인을 받더라도, 커밋 자체를 차단합니다. - if (action == "commit") - { - return ToolResult.Fail( - "Git 커밋 기능은 현재 비활성 상태입니다.\n" + - "안전을 위해 커밋은 사용자가 직접 수행하세요.\n" + - "향후 버전에서 활성화될 예정입니다."); - } - // 명령 실행 try { diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 9cc00e2..f020533 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -5,6 +5,7 @@ using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Threading; +using Microsoft.Win32; using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; @@ -30,6 +31,18 @@ public partial class ChatWindow : Window private bool _isStreaming; private bool _sidebarVisible = true; private string _selectedCategory = ""; // "" = 전체 + private readonly Dictionary _tabSelectedCategory = new(StringComparer.OrdinalIgnoreCase) + { + ["Chat"] = "", + ["Cowork"] = "", + ["Code"] = "", + }; + private readonly Dictionary _tabSidebarVisible = new(StringComparer.OrdinalIgnoreCase) + { + ["Chat"] = true, + ["Cowork"] = true, + ["Code"] = true, + }; private bool _failedOnlyFilter; private bool _runningOnlyFilter; private int _failedConversationCount; @@ -50,6 +63,8 @@ public partial class ChatWindow : Window private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기 private PlanViewerWindow? _planViewerWindow; // 실행 계획 뷰어 private bool _userScrolled; // 사용자가 위로 스크롤했는지 + private readonly HashSet _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase); // 경과 시간 표시 private readonly DispatcherTimer _elapsedTimer; @@ -59,6 +74,7 @@ public partial class ChatWindow : Window // 타이핑 효과 private readonly DispatcherTimer _typingTimer; private int _displayedLength; // 현재 화면에 표시된 글자 수 + private ResourceDictionary? _agentThemeDictionary; private sealed class ConversationMeta { @@ -93,24 +109,36 @@ public partial class ChatWindow : Window _toolRegistry = ToolRegistry.CreateDefault(); _agentLoop = new AgentLoopService(_llm, _toolRegistry, settings) { - Dispatcher = action => System.Windows.Application.Current.Dispatcher.Invoke(action), + Dispatcher = action => + { + var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; + appDispatcher.Invoke(action); + }, AskPermissionCallback = async (toolName, filePath) => { - var result = MessageBoxResult.None; - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + if (IsPermissionAutoApprovedForSession(toolName, filePath)) + return true; + + PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject; + var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; + await appDispatcher.InvokeAsync(() => { - result = CustomMessageBox.Show( - $"도구 '{toolName}'이(가) 다음 파일에 접근하려 합니다:\n\n{filePath}\n\n허용하시겠습니까?", - "AX Agent — 권한 확인", - MessageBoxButton.YesNo, - MessageBoxImage.Question); + AgentLoopService.PermissionPromptPreview? preview = null; + if (_agentLoop != null && _agentLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview)) + preview = pendingPreview; + decision = PermissionRequestWindow.Show(this, toolName, filePath, preview); }); - return result == MessageBoxResult.Yes; + + if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession) + RememberPermissionRuleForSession(toolName, filePath); + + return decision != PermissionRequestWindow.PermissionPromptResult.Reject; }, UserAskCallback = async (question, options, defaultValue) => { string? response = null; - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + var appDispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher; + await appDispatcher.InvokeAsync(() => { response = UserAskDialog.Show(question, options, defaultValue); }); @@ -138,6 +166,8 @@ public partial class ChatWindow : Window UpdateConversationRunningFilterUi(); Loaded += (_, _) => { + ApplyAgentThemeResources(); + // ── 즉시 필요한 UI 초기화만 동기 실행 ── SetupUserInfo(); _selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern"; @@ -145,6 +175,8 @@ public partial class ChatWindow : Window UpdateAnalyzerButtonVisibility(); UpdateModelLabel(); RefreshInlineSettingsPanel(); + ApplyExpressionLevelUi(); + UpdateSidebarModeMenu(); InputBox.Focus(); MessageScroll.ScrollChanged += MessageScroll_ScrollChanged; @@ -248,6 +280,76 @@ public partial class ChatWindow : Window }; } + private bool IsPermissionAutoApprovedForSession(string toolName, string target) + { + if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) + return false; + + var normalizedTarget = target.Trim(); + var pathLikeTool = IsPathLikePermissionTool(toolName); + foreach (var rule in _sessionPermissionRules) + { + var pivot = rule.IndexOf('|'); + if (pivot <= 0 || pivot >= rule.Length - 1) + continue; + + var ruleTool = rule[..pivot]; + var ruleTarget = rule[(pivot + 1)..]; + if (!string.Equals(ruleTool, toolName, StringComparison.OrdinalIgnoreCase)) + continue; + + if (pathLikeTool) + { + if (normalizedTarget.StartsWith(ruleTarget, StringComparison.OrdinalIgnoreCase) + || string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase)) + return true; + } + else if (string.Equals(normalizedTarget, ruleTarget, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private void RememberPermissionRuleForSession(string toolName, string target) + { + if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) + return; + + var normalizedTarget = target.Trim(); + var scopedTarget = normalizedTarget; + + if (IsPathLikePermissionTool(toolName)) + { + try + { + if (System.IO.Path.IsPathRooted(normalizedTarget)) + { + var full = System.IO.Path.GetFullPath(normalizedTarget); + var directory = System.IO.Path.GetDirectoryName(full); + if (!string.IsNullOrWhiteSpace(directory)) + scopedTarget = directory.TrimEnd('\\', '/') + System.IO.Path.DirectorySeparatorChar; + } + } + catch + { + // Ignore invalid path and fall back to exact target matching. + } + } + + _sessionPermissionRules.Add($"{toolName}|{scopedTarget}"); + } + + private static bool IsPathLikePermissionTool(string toolName) + { + var normalized = toolName.Trim().ToLowerInvariant(); + return normalized.Contains("file", StringComparison.Ordinal) + || normalized.Contains("edit", StringComparison.Ordinal) + || normalized.Contains("write", StringComparison.Ordinal); + } + /// /// X 버튼으로 닫을 때 창을 숨기기만 합니다 (재사용으로 다음 번 빠르게 열림). /// 앱 종료 시에는 ForceClose()를 사용합니다. @@ -621,6 +723,85 @@ public partial class ChatWindow : Window } } + private void UpdateSidebarModeMenu() + { + if (SidebarChatMenu == null || SidebarCoworkMenu == null || SidebarCodeMenu == null) + return; + + SidebarChatMenu.Visibility = _activeTab == "Chat" ? Visibility.Visible : Visibility.Collapsed; + SidebarCoworkMenu.Visibility = _activeTab == "Cowork" ? Visibility.Visible : Visibility.Collapsed; + SidebarCodeMenu.Visibility = _activeTab == "Code" ? Visibility.Visible : Visibility.Collapsed; + + if (SidebarModeBadgeTitle != null && SidebarModeBadgeIcon != null) + { + if (_activeTab == "Cowork") + { + SidebarModeBadgeTitle.Text = "Cowork 메뉴"; + SidebarModeBadgeIcon.Text = "\uE8FD"; + } + else if (_activeTab == "Code") + { + SidebarModeBadgeTitle.Text = "Code 메뉴"; + SidebarModeBadgeIcon.Text = "\uE943"; + } + else + { + SidebarModeBadgeTitle.Text = "Chat 메뉴"; + SidebarModeBadgeIcon.Text = "\uE8BD"; + } + } + + if (SidebarChatFailedState != null) + SidebarChatFailedState.Text = _failedOnlyFilter ? "ON" : "OFF"; + if (SidebarChatRunningState != null) + SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF"; + } + + private void SidebarChatAll_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + _selectedCategory = ""; + UpdateCategoryLabel(); + RefreshConversationList(); + } + + private void SidebarChatFailed_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnFailedOnlyFilter_Click(this, new RoutedEventArgs()); + + private void SidebarChatRunning_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnRunningOnlyFilter_Click(this, new RoutedEventArgs()); + + private void SidebarCoworkCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnCategoryDrop_Click(this, new RoutedEventArgs()); + + private void SidebarCoworkPreset_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + BuildTopicButtons(); + ShowToast("프리셋 카드가 갱신되었습니다."); + } + + private void SidebarCoworkExecution_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnToggleExecutionLog_Click(this, new RoutedEventArgs()); + + private void SidebarCodeCategory_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + => BtnCategoryDrop_Click(this, new RoutedEventArgs()); + + private void SidebarCodeLanguage_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + BuildCodeBottomBar(); + ShowToast("코드 옵션 메뉴를 갱신했습니다."); + } + + private void SidebarCodeFiles_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) + { + if (FileBrowserPanel == null) + return; + + var visible = FileBrowserPanel.Visibility == Visibility.Visible; + FileBrowserPanel.Visibility = visible ? Visibility.Collapsed : Visibility.Visible; + if (!visible) + BuildFileTree(); + } + // ─── 창 컨트롤 ────────────────────────────────────────────────────── // WindowChrome의 CaptionHeight가 드래그를 처리하므로 별도 핸들러 불필요 @@ -633,6 +814,46 @@ public partial class ChatWindow : Window source?.AddHook(WndProc); } + private void ApplyAgentThemeResources() + { + var selected = (_settings.Settings.Llm.AgentTheme ?? "system").Trim().ToLowerInvariant(); + var effective = selected switch + { + "light" => "AgentLight", + "dark" => "AgentDark", + _ => IsSystemDarkTheme() ? "AgentDark" : "AgentLight", + }; + + try + { + if (_agentThemeDictionary != null) + Resources.MergedDictionaries.Remove(_agentThemeDictionary); + + _agentThemeDictionary = new ResourceDictionary + { + Source = new Uri($"pack://application:,,,/Themes/{effective}.xaml"), + }; + Resources.MergedDictionaries.Insert(0, _agentThemeDictionary); + } + catch + { + // 테마 로드 실패 시 기본 리소스 유지 + } + } + + private static bool IsSystemDarkTheme() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + return key?.GetValue("AppsUseLightTheme") is int v && v == 0; + } + catch + { + return true; + } + } + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { // WM_GETMINMAXINFO — 최대화 시 작업 표시줄 영역 확보 @@ -711,8 +932,9 @@ public partial class ChatWindow : Window if (_activeTab == "Chat") return; StopStreamingIfActive(); SaveCurrentTabConversationId(); + PersistPerTabUiState(); _activeTab = "Chat"; - _selectedCategory = ""; UpdateCategoryLabel(); + RestorePerTabUiState(); UpdateTabUI(); } @@ -721,8 +943,9 @@ public partial class ChatWindow : Window if (_activeTab == "Cowork") return; StopStreamingIfActive(); SaveCurrentTabConversationId(); + PersistPerTabUiState(); _activeTab = "Cowork"; - _selectedCategory = ""; UpdateCategoryLabel(); + RestorePerTabUiState(); UpdateTabUI(); } @@ -731,8 +954,9 @@ public partial class ChatWindow : Window if (_activeTab == "Code") return; StopStreamingIfActive(); SaveCurrentTabConversationId(); + PersistPerTabUiState(); _activeTab = "Code"; - _selectedCategory = ""; UpdateCategoryLabel(); + RestorePerTabUiState(); UpdateTabUI(); } @@ -764,6 +988,10 @@ public partial class ChatWindow : Window private void UpdateTabUI() { + ApplyAgentThemeResources(); + ApplyExpressionLevelUi(); + ApplySidebarStateForActiveTab(animated: false); + // 폴더 바는 Cowork/Code 탭에서만 표시 if (FolderBar != null) FolderBar.Visibility = _activeTab != "Chat" ? Visibility.Visible : Visibility.Collapsed; @@ -811,6 +1039,7 @@ public partial class ChatWindow : Window // 탭별 프리셋 버튼 재구성 BuildTopicButtons(); + UpdateSidebarModeMenu(); // 현재 대화를 해당 탭 대화로 전환 SwitchToTabConversation(); @@ -819,6 +1048,88 @@ public partial class ChatWindow : Window ShowRandomTip(); } + private void PersistPerTabUiState() + { + _tabSelectedCategory[_activeTab] = _selectedCategory; + _tabSidebarVisible[_activeTab] = _sidebarVisible; + } + + private void RestorePerTabUiState() + { + if (_tabSelectedCategory.TryGetValue(_activeTab, out var category)) + _selectedCategory = category ?? ""; + else + _selectedCategory = ""; + UpdateCategoryLabel(); + + if (_tabSidebarVisible.TryGetValue(_activeTab, out var visible)) + _sidebarVisible = visible; + else + _sidebarVisible = true; + } + + private string GetAgentUiExpressionLevel() + { + var raw = _settings.Settings.Llm.AgentUiExpressionLevel; + return (raw ?? "balanced").Trim().ToLowerInvariant() switch + { + "rich" => "rich", + "simple" => "simple", + _ => "balanced", + }; + } + + private void ApplyExpressionLevelUi() + { + var level = GetAgentUiExpressionLevel(); + + if (InputBox != null) + { + InputBox.FontSize = level == "simple" ? 13 : 14; + InputBox.MaxHeight = level switch + { + "rich" => 220, + "simple" => 120, + _ => 160, + }; + } + + if (InputWatermark != null) + { + InputWatermark.FontSize = level == "simple" ? 13 : 14; + InputWatermark.Opacity = level == "rich" ? 0.8 : 0.7; + } + + if (InlineSettingsHintText != null) + InlineSettingsHintText.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible; + + if (InlineSettingsQuickActions != null) + InlineSettingsQuickActions.Visibility = level == "simple" ? Visibility.Collapsed : Visibility.Visible; + + if (BtnTemplateSelector != null) + { + BtnTemplateSelector.Padding = level == "simple" + ? new Thickness(8, 4, 8, 4) + : new Thickness(10, 6, 10, 6); + } + + // simple/balanced 모드에서는 실패 전용 필터 UI를 숨겨 정보 과밀도를 줄입니다. + var showFailureFilter = level == "rich"; + if (!showFailureFilter && _failedOnlyFilter) + { + _failedOnlyFilter = false; + RefreshConversationList(); + UpdateConversationFailureFilterUi(); + } + + if (SidebarChatFailedRow != null) + SidebarChatFailedRow.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed; + if (BtnFailedOnlyFilter != null) + BtnFailedOnlyFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed; + if (BtnQuickFailedFilter != null) + BtnQuickFailedFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed; + } + private void SwitchToTabConversation() { var session = ChatSession; @@ -1956,20 +2267,38 @@ public partial class ChatWindow : Window private void BtnToggleSidebar_Click(object sender, RoutedEventArgs e) { _sidebarVisible = !_sidebarVisible; + _tabSidebarVisible[_activeTab] = _sidebarVisible; + ApplySidebarStateForActiveTab(animated: true); + } + + private void ApplySidebarStateForActiveTab(bool animated) + { + var targetVisible = _tabSidebarVisible.TryGetValue(_activeTab, out var visible) ? visible : true; + _sidebarVisible = targetVisible; + if (_sidebarVisible) { - // 사이드바 열기, 아이콘 바 숨기기 IconBarColumn.Width = new GridLength(0); IconBarPanel.Visibility = Visibility.Collapsed; SidebarPanel.Visibility = Visibility.Visible; ToggleSidebarIcon.Text = "\uE76B"; - AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200); + + if (animated) + { + AnimateSidebar(0, 270, () => SidebarColumn.MinWidth = 200); + } + else + { + SidebarColumn.MinWidth = 200; + SidebarColumn.Width = new GridLength(270); + } + return; } - else + + SidebarColumn.MinWidth = 0; + ToggleSidebarIcon.Text = "\uE76C"; + if (animated) { - // 사이드바 닫기, 아이콘 바 표시 - SidebarColumn.MinWidth = 0; - ToggleSidebarIcon.Text = "\uE76C"; AnimateSidebar(270, 0, () => { SidebarPanel.Visibility = Visibility.Collapsed; @@ -1977,6 +2306,13 @@ public partial class ChatWindow : Window IconBarPanel.Visibility = Visibility.Visible; }); } + else + { + SidebarColumn.Width = new GridLength(0); + SidebarPanel.Visibility = Visibility.Collapsed; + IconBarColumn.Width = new GridLength(52); + IconBarPanel.Visibility = Visibility.Visible; + } } private void AnimateSidebar(double from, double to, Action? onComplete = null) @@ -2967,7 +3303,7 @@ public partial class ChatWindow : Window private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { - _failedOnlyFilter = !_failedOnlyFilter; + _failedOnlyFilter = false; UpdateConversationFailureFilterUi(); PersistConversationListPreferences(); RefreshConversationList(); @@ -2975,7 +3311,7 @@ public partial class ChatWindow : Window private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e) { - _runningOnlyFilter = !_runningOnlyFilter; + _runningOnlyFilter = false; UpdateConversationRunningFilterUi(); PersistConversationListPreferences(); RefreshConversationList(); @@ -3028,6 +3364,7 @@ public partial class ChatWindow : Window : _failedConversationCount > 0 ? $"실패한 에이전트 실행이 있는 대화 {_failedConversationCount}개 보기" : "실패한 에이전트 실행이 있는 대화만 보기"; + UpdateSidebarModeMenu(); } private void UpdateConversationRunningFilterUi() @@ -3055,6 +3392,7 @@ public partial class ChatWindow : Window : _runningConversationCount > 0 ? $"현재 실행 중인 대화 {_runningConversationCount}개 보기" : "현재 실행 중인 대화만 보기"; + UpdateSidebarModeMenu(); } private void UpdateConversationSortUi() @@ -3234,10 +3572,18 @@ public partial class ChatWindow : Window private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null) { var isUser = role == "user"; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40)); + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var hintBg = TryFindResource("HintBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; if (isUser) { - // 사용자: 우측 정렬, 악센트 배경 + 편집 버튼 + // 사용자: 우측 정렬, 중립 카드 + 편집 버튼 var wrapper = new StackPanel { HorizontalAlignment = HorizontalAlignment.Right, @@ -3247,14 +3593,16 @@ public partial class ChatWindow : Window var bubble = new Border { - Background = TryFindResource("AccentColor") as Brush ?? Brushes.Blue, - CornerRadius = new CornerRadius(16, 4, 16, 16), + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), Padding = new Thickness(16, 10, 16, 10), Child = new TextBlock { Text = content, FontSize = 13.5, - Foreground = Brushes.White, + Foreground = primaryText, TextWrapping = TextWrapping.Wrap, LineHeight = 21, } @@ -3270,7 +3618,7 @@ public partial class ChatWindow : Window Margin = new Thickness(0, 2, 0, 0), }; var capturedUserContent = content; - var userBtnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var userBtnColor = secondaryText; userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () => { try { Clipboard.SetText(capturedUserContent); } catch { } @@ -3308,7 +3656,7 @@ public partial class ChatWindow : Window } else { - // 어시스턴트: 좌측 정렬, 다크 배경 + // 어시스턴트: 좌측 정렬, 정돈된 카드 var container = new StackPanel { HorizontalAlignment = HorizontalAlignment.Left, @@ -3318,30 +3666,18 @@ public partial class ChatWindow : Window if (animate) ApplyMessageEntryAnimation(container); // AI 에이전트 이름 + 아이콘 - var (agentName, agentSymbol, agentColor) = GetAgentIdentity(); - var agentBrush = BrushFromHex(agentColor); + var (agentName, _, _) = GetAgentIdentity(); var headerSp = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) }; // 다이아몬드 심볼 아이콘 (회전 애니메이션) var iconBlock = new TextBlock { - Text = "◆", - FontSize = 13, - Foreground = agentBrush, + Text = "\uE8BD", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, - RenderTransformOrigin = new Point(0.5, 0.5), - RenderTransform = new RotateTransform(0), }; - if (animate) - { - var spin = new System.Windows.Media.Animation.DoubleAnimation - { - From = 0, To = 360, - Duration = TimeSpan.FromSeconds(1.2), - EasingFunction = new System.Windows.Media.Animation.CubicEase { EasingMode = System.Windows.Media.Animation.EasingMode.EaseOut }, - }; - ((RotateTransform)iconBlock.RenderTransform).BeginAnimation(RotateTransform.AngleProperty, spin); - } headerSp.Children.Add(iconBlock); headerSp.Children.Add(new TextBlock @@ -3349,19 +3685,26 @@ public partial class ChatWindow : Window Text = agentName, FontSize = 11, FontWeight = FontWeights.SemiBold, - Foreground = agentBrush, + Foreground = secondaryText, Margin = new Thickness(6, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center, }); container.Children.Add(headerSp); + var contentCard = new Border + { + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(14), + Padding = new Thickness(14, 10, 14, 10), + }; + var contentStack = new StackPanel(); + // 마크다운 렌더링 (파일 경로 강조 설정 연동) var app = System.Windows.Application.Current as App; MarkdownRenderer.EnableFilePathHighlight = app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true; - var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; - var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray; if (IsBranchContextMessage(content)) { @@ -3369,10 +3712,10 @@ public partial class ChatWindow : Window var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3); var branchCard = new Border { - Background = BrushFromHex("#EEF2FF"), - BorderBrush = BrushFromHex("#C7D2FE"), + Background = hintBg, + BorderBrush = borderBrush, BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(14), + CornerRadius = new CornerRadius(10), Padding = new Thickness(14, 12, 14, 12), Margin = new Thickness(0, 0, 0, 4), }; @@ -3382,10 +3725,10 @@ public partial class ChatWindow : Window Text = "분기 컨텍스트", FontSize = 11, FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex("#4338CA"), + Foreground = primaryText, Margin = new Thickness(0, 0, 0, 6), }); - var branchMd = MarkdownRenderer.Render(content, BrushFromHex("#312E81"), secondaryText, accentBrush, BrushFromHex("#E0E7FF")); + var branchMd = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush); branchStack.Children.Add(branchMd); if (branchFiles.Count > 0) { @@ -3397,8 +3740,8 @@ public partial class ChatWindow : Window { var fileButton = new Button { - Background = BrushFromHex("#E0E7FF"), - BorderBrush = BrushFromHex("#C7D2FE"), + Background = itemBg, + BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(8, 3, 8, 3), Margin = new Thickness(0, 0, 6, 6), @@ -3409,7 +3752,7 @@ public partial class ChatWindow : Window Text = System.IO.Path.GetFileName(path), FontSize = 10, FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex("#4338CA"), + Foreground = primaryText, } }; var capturedPath = path; @@ -3428,8 +3771,8 @@ public partial class ChatWindow : Window var followUpButton = new Button { - Background = BrushFromHex("#E0E7FF"), - BorderBrush = BrushFromHex("#C7D2FE"), + Background = itemBg, + BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(8, 3, 8, 3), Margin = new Thickness(0, 0, 6, 6), @@ -3439,7 +3782,7 @@ public partial class ChatWindow : Window Text = "후속 작업 큐에 넣기", FontSize = 10, FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex("#4338CA"), + Foreground = primaryText, } }; var capturedBranchRun = branchRun; @@ -3448,8 +3791,8 @@ public partial class ChatWindow : Window var timelineButton = new Button { - Background = BrushFromHex("#F5F3FF"), - BorderBrush = BrushFromHex("#DDD6FE"), + Background = itemBg, + BorderBrush = borderBrush, BorderThickness = new Thickness(1), Padding = new Thickness(8, 3, 8, 3), Margin = new Thickness(0, 0, 6, 6), @@ -3459,7 +3802,7 @@ public partial class ChatWindow : Window Text = "관련 로그로 이동", FontSize = 10, FontWeight = FontWeights.SemiBold, - Foreground = BrushFromHex("#5B21B6"), + Foreground = primaryText, } }; timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId); @@ -3468,25 +3811,26 @@ public partial class ChatWindow : Window branchStack.Children.Add(actionsWrap); } branchCard.Child = branchStack; - container.Children.Add(branchCard); + contentStack.Children.Add(branchCard); } else { var mdPanel = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush); - mdPanel.Margin = new Thickness(0, 0, 0, 4); - container.Children.Add(mdPanel); + contentStack.Children.Add(mdPanel); } + contentCard.Child = contentStack; + container.Children.Add(contentCard); // 액션 버튼 바 (복사 / 좋아요 / 싫어요) var actionBar = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 6, 0, 0) + Margin = new Thickness(0, 6, 0, 0), + Opacity = 0 }; - var btnColor = (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); - var btnHoverColor = new SolidColorBrush(Color.FromRgb(0x8B, 0x90, 0xB0)); + var btnColor = secondaryText; var capturedContent = content; actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () => @@ -3509,6 +3853,8 @@ public partial class ChatWindow : Window }); container.Children.Add(actionBar); + container.MouseEnter += (_, _) => actionBar.Opacity = 1; + container.MouseLeave += (_, _) => actionBar.Opacity = 0; // 우클릭 → 메시지 컨텍스트 메뉴 var aiContent = content; @@ -3528,18 +3874,11 @@ public partial class ChatWindow : Window /// 현재 테마의 체크 스타일을 반환합니다. private string GetCheckStyle() { - var theme = (_settings.Settings.Launcher.Theme ?? "system").ToLowerInvariant(); + var theme = (_settings.Settings.Llm.AgentTheme ?? "system").ToLowerInvariant(); return theme switch { - "dark" or "system" => "circle", // 원 + 체크마크, 바운스 - "oled" => "glow", // 네온 글로우 원, 페이드인 - "light" => "roundrect", // 둥근 사각형, 슬라이드인 - "nord" => "diamond", // 다이아몬드(마름모), 스무스 스케일 - "catppuccin" => "pill", // 필 모양, 스프링 바운스 - "monokai" => "square", // 정사각형, 퀵 팝 - "sepia" => "stamp", // 도장 스타일 원, 회전 등장 - "alfred" => "minimal", // 미니멀 원, 우아한 페이드 - "alfredlight" => "minimal", // 미니멀 원, 우아한 페이드 + "dark" or "system" => "circle", + "light" => "roundrect", _ => "circle", }; } @@ -4396,6 +4735,64 @@ public partial class ChatWindow : Window private static readonly Dictionary SlashCommands = new(StringComparer.OrdinalIgnoreCase) { // 공통 + ["/clear"] = ("Clear", "__CLEAR__", "all"), + ["/new"] = ("New", "__NEW__", "all"), + ["/reset"] = ("Reset", "__RESET__", "all"), + ["/status"] = ("Status", "__STATUS__", "all"), + ["/model"] = ("Model", "__MODEL__", "all"), + ["/permissions"] = ("Permissions", "__PERMISSIONS__", "all"), + ["/allowed-tools"] = ("Allowed Tools", "__ALLOWED_TOOLS__", "all"), + ["/theme"] = ("Theme", "__THEME__", "all"), + ["/color"] = ("Color", "현재 테마/색상 구성을 점검하고 가독성 중심 개선안을 제시하세요.", "all"), + ["/config"] = ("Config", "현재 설정 상태를 점검하고, 목적에 맞는 권장 설정 변경안을 제시하세요.", "all"), + ["/settings"] = ("Settings", "__SETTINGS__", "all"), + ["/session"] = ("Session", "현재 세션의 핵심 맥락과 이어서 할 일을 5개 이내로 정리하세요.", "all"), + ["/usage"] = ("Usage", "현재 사용 흐름을 개선할 수 있는 사용 팁을 간결히 제시하세요.", "all"), + ["/upgrade"] = ("Upgrade", "현재 작업/구성 기준으로 안전한 업그레이드 체크리스트를 제시하세요.", "all"), + ["/copy"] = ("Copy", "사용자가 바로 복사해 쓸 수 있는 최종 결과 형태로 간결하게 답변하세요.", "all"), + ["/rename"] = ("Rename", "__RENAME__", "all"), + ["/feedback"] = ("Feedback", "__FEEDBACK__", "all"), + ["/skills"] = ("Skills", "__SKILLS__", "all"), + ["/sandbox-toggle"] = ("Sandbox Toggle", "__SANDBOX_TOGGLE__", "all"), + ["/statusline"] = ("Statusline", "__STATUSLINE__", "all"), + ["/heapdump"] = ("Heap Dump", "__HEAPDUMP__", "all"), + ["/passes"] = ("Passes", "__PASSES__", "all"), + ["/chrome"] = ("Chrome", "__CHROME__", "all"), + ["/stickers"] = ("Stickers", "__STICKERS__", "all"), + ["/thinkback"] = ("Thinkback", "__THINKBACK__", "all"), + ["/thinkback-play"] = ("Thinkback Play", "__THINKBACK_PLAY__", "all"), + ["/exit"] = ("Exit", "현재 대화를 마무리하기 위한 요약과 다음 재개 지점을 3줄 이내로 제시하세요.", "all"), + ["/login"] = ("Login", "인증/연결 점검 절차를 단계별로 안내하세요.", "all"), + ["/logout"] = ("Logout", "안전한 로그아웃 및 세션 정리 체크리스트를 안내하세요.", "all"), + ["/desktop"] = ("Desktop", "데스크톱 실행/연결 환경 점검 체크리스트를 제시하세요.", "all"), + ["/mobile"] = ("Mobile", "모바일 연동/사용 시 주의사항과 권장 구성을 제시하세요.", "all"), + ["/ide"] = ("IDE", "IDE 연동 상태를 점검하고 생산성 향상 팁을 제시하세요.", "all"), + ["/terminal-setup"] = ("Terminal Setup", "터미널 환경 초기 설정 체크리스트를 제시하세요.", "all"), + ["/add-dir"] = ("Add Dir", "작업 디렉터리 추가 시 권장 구조와 주의사항을 제시하세요.", "all"), + ["/advisor"] = ("Advisor", "현재 요청에 대한 실행 전략을 조언자 관점으로 간결히 제시하세요.", "all"), + ["/mcp"] = ("MCP", "__MCP__", "all"), + ["/agents"] = ("Agents", "현재 작업에 적합한 에이전트 분담 전략을 제시하세요.", "all"), + ["/plugin"] = ("Plugin", "플러그인 사용/구성 상태를 점검하고 권장 구성을 제시하세요.", "all"), + ["/reload-plugins"] = ("Reload Plugins", "플러그인 재로드 전후 점검 체크리스트를 제시하세요.", "all"), + ["/output-style"] = ("Output Style", "현재 작업 목적에 맞는 출력 스타일 가이드를 제시하세요.", "all"), + ["/remote-env"] = ("Remote Env", "원격 환경 연결 시 필수 점검 항목을 제시하세요.", "all"), + ["/install-github-app"] = ("Install GitHub App", "GitHub 앱 연동 절차와 점검 항목을 제시하세요.", "all"), + ["/install-slack-app"] = ("Install Slack App", "Slack 앱 연동 절차와 점검 항목을 제시하세요.", "all"), + ["/btw"] = ("BTW", "현재 맥락에서 추가로 유용한 보조 팁을 짧게 제시하세요.", "all"), + ["/keybindings"] = ("Keybindings", "주요 단축키/입력 효율화 팁을 현재 작업 기준으로 정리하세요.", "all"), + ["/privacy-settings"] = ("Privacy", "보안/개인정보 관점의 권장 설정을 점검표 형태로 제시하세요.", "all"), + ["/rate-limit-options"] = ("Rate Limit", "요청 한도 이슈를 줄이기 위한 설정/사용 전략을 제시하세요.", "all"), + ["/release-notes"] = ("Release Notes", "최근 변경사항을 릴리즈 노트 형식으로 정리하세요.", "all"), + ["/rewind"] = ("Rewind", "직전 변경 이전 상태로 되돌릴 때의 안전 절차를 제시하세요.", "all"), + ["/tag"] = ("Tag", "현재 작업 결과를 태깅/분류하는 기준을 제안하세요.", "all"), + ["/vim"] = ("Vim", "Vim 스타일 작업 효율 팁을 현재 작업에 맞춰 제시하세요.", "all"), + ["/plan"] = ("Plan", "요청을 바로 실행하지 말고 먼저 3~7단계 실행 계획을 간결히 제시한 뒤, 사용자 승인 후 실행하세요.", "all"), + ["/memory"] = ("Memory", "대화 맥락에서 지속적으로 참고할 핵심 사실을 정리하고, 필요한 경우 memory 도구 저장/갱신 관점으로 답변하세요.", "all"), + ["/context"] = ("Context", "현재 작업 컨텍스트(목표, 제약, 현재 상태, 다음 액션)를 구조화해 요약하세요.", "all"), + ["/stats"] = ("Stats", "__STATS__", "all"), + ["/cost"] = ("Cost", "__COST__", "all"), + ["/export"] = ("Export", "__EXPORT__", "all"), + ["/compact"] = ("Compact", "__COMPACT__", "all"), ["/summary"] = ("Summary", "사용자가 제공한 내용을 핵심 포인트 위주로 간결하게 요약해 주세요. 불릿 포인트 형식을 사용하세요.", "all"), ["/translate"] = ("Translate", "사용자가 제공한 텍스트를 영어로 번역해 주세요. 원문의 톤과 뉘앙스를 유지하세요.", "all"), ["/explain"] = ("Explain", "사용자가 제공한 내용을 쉽고 자세하게 설명해 주세요. 필요하면 예시를 포함하세요.", "all"), @@ -4403,11 +4800,27 @@ public partial class ChatWindow : Window // Cowork/Code 전용 ["/review"] = ("Code Review", "작업 폴더의 git diff를 분석하여 코드 리뷰를 수행해 주세요. code_review 도구를 사용하세요.", "dev"), + ["/commit"] = ("Commit", "__COMMIT__", "dev"), + ["/ultrareview"] = ("Ultra Review", "작업 폴더의 변경을 P0/P1 우선순위 기준으로 강도 높게 리뷰하고, 재현 조건/수정 방향/테스트 누락까지 제시하세요.", "dev"), + ["/security-review"] = ("Security Review", "작업 폴더 코드를 보안 중심으로 점검하고 취약점/개선안을 제시하세요.", "dev"), ["/pr"] = ("PR Summary", "작업 폴더의 변경사항을 PR 설명 형식으로 요약해 주세요. code_review(action: pr_summary) 도구를 사용하세요.", "dev"), + ["/pr-comments"] = ("PR Comments", "변경사항을 검토하고 PR 코멘트 형태로 개선 의견을 작성하세요.", "dev"), ["/test"] = ("Test", "작업 폴더의 코드에 대한 단위 테스트를 생성해 주세요. test_loop 도구를 사용하세요.", "dev"), + ["/verify"] = ("Verify", "__VERIFY__", "dev"), ["/structure"] = ("Structure", "작업 폴더의 프로젝트 구조를 분석하고 설명해 주세요. folder_map 도구를 사용하세요.", "dev"), ["/build"] = ("Build", "작업 폴더의 프로젝트를 빌드해 주세요. build_run 도구를 사용하세요.", "dev"), ["/search"] = ("Search", "작업 폴더에서 관련 코드를 검색해 주세요. search_codebase 도구를 사용하세요.", "dev"), + ["/diff"] = ("Diff", "현재 작업 폴더의 변경(diff)을 분석해 핵심 변경점과 리스크를 요약하세요.", "dev"), + ["/doctor"] = ("Doctor", "현재 프로젝트/환경의 잠재 이슈를 점검하고 우선순위별 개선안을 제시하세요.", "dev"), + ["/hooks"] = ("Hooks", "현재 훅/자동화 관련 설정을 점검하고 안전한 운영 가이드를 제시하세요.", "dev"), + ["/tasks"] = ("Tasks", "현재 작업을 태스크 단위로 분해해 우선순위와 다음 실행 항목을 제시하세요.", "dev"), + ["/branch"] = ("Branch", "현재 변경 기준으로 적절한 브랜치/커밋 전략을 제안하세요.", "dev"), + ["/files"] = ("Files", "현재 작업에서 핵심 파일과 변경 후보를 우선순위로 정리하세요.", "dev"), + ["/resume"] = ("Resume", "이전 작업 맥락을 복원한다고 가정하고 현재 상태/남은 작업을 이어서 정리하세요.", "dev"), + ["/init"] = ("Init", "프로젝트 온보딩 관점에서 초기 점검 체크리스트를 제시하세요.", "dev"), + ["/init-verifiers"] = ("Init Verifiers", "검증 자동화(빌드/테스트/리뷰) 초기 구성을 위한 체크리스트를 제시하세요.", "dev"), + ["/effort"] = ("Effort", "현재 요청의 난이도와 권장 추론 강도를 제안하세요.", "dev"), + ["/fast"] = ("Fast", "속도 우선 모드로 진행할 때의 최소 안전 절차를 제시하세요.", "dev"), // 특수 ["/help"] = ("Help", "__HELP__", "all"), @@ -4489,8 +4902,8 @@ public partial class ChatWindow : Window var totalSkills = _slashAllMatches.Count(x => x.IsSkill); var totalCommands = total - totalSkills; - SlashPopupTitle.Text = "명령/스킬 브라우저"; - SlashPopupHint.Text = $"명령 {totalCommands}개 · 스킬 {totalSkills}개"; + SlashPopupTitle.Text = "명령 팔레트"; + SlashPopupHint.Text = $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · /compact 지원"; // 위 화살표 if (start > 0) @@ -4788,15 +5201,17 @@ public partial class ChatWindow : Window /// 슬래시 명령어를 감지하여 시스템 프롬프트와 사용자 텍스트를 분리합니다. private (string? slashSystem, string userText) ParseSlashCommand(string input) { - // 내장 명령어 우선 - foreach (var (cmd, (_, prompt, _)) in SlashCommands) + var trimmed = input.TrimStart(); + if (trimmed.StartsWith("/")) { - if (input.StartsWith(cmd, StringComparison.OrdinalIgnoreCase)) + var firstSpace = trimmed.IndexOf(' '); + var commandToken = (firstSpace >= 0 ? trimmed[..firstSpace] : trimmed).Trim(); + if (SlashCommands.TryGetValue(commandToken, out var entry)) { // __HELP__는 특수 처리 (ParseSlashCommand에서는 무시) - if (prompt == "__HELP__") return (null, input); - var rest = input[cmd.Length..].Trim(); - return (prompt, string.IsNullOrEmpty(rest) ? cmd : rest); + if (entry.SystemPrompt == "__HELP__") return (null, input); + var rest = firstSpace >= 0 ? trimmed[(firstSpace + 1)..].Trim() : ""; + return (entry.SystemPrompt, string.IsNullOrEmpty(rest) ? commandToken : rest); } } @@ -5055,33 +5470,81 @@ public partial class ChatWindow : Window contentPanel.Children.Add(new TextBlock { Text = "입력창에 /를 입력하면 사용할 수 있는 명령어가 표시됩니다.\n명령어를 선택한 후 내용을 입력하면 해당 기능이 적용됩니다.", FontSize = 12, Foreground = fg2, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16), LineHeight = 20 }); // 공통 명령어 섹션 - AddHelpSection(contentPanel, "📌 공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg, - ("/summary", "텍스트/문서를 핵심 포인트 중심으로 요약합니다."), - ("/translate", "텍스트를 영어로 번역합니다. 원문의 톤을 유지합니다."), - ("/explain", "내용을 쉽고 자세하게 설명합니다. 예시를 포함합니다."), - ("/fix", "맞춤법, 문법, 자연스러운 표현을 교정합니다.")); + AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg, + ("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."), + ("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다."), + ("/new", "새 대화를 시작합니다."), + ("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."), + ("/model", "모델 선택 패널을 엽니다."), + ("/permissions", "권한 설정 패널을 엽니다."), + ("/allowed-tools", "허용 도구(권한) 패널을 엽니다."), + ("/settings", "AX Agent 설정 창을 엽니다."), + ("/stats", "최근 호출의 토큰 통계를 표시합니다."), + ("/cost", "최근 호출의 추정 비용을 표시합니다."), + ("/export", "현재 대화를 파일로 내보냅니다."), + ("/clear", "현재 대화를 정리하고 새 대화를 시작합니다.")); + + AddHelpSection(contentPanel, "작업/운영 명령어", "claw-code 명령 체계를 참고한 운영 명령", fg, fg2, accent, itemBg, hoverBg, + ("/config", "설정 점검 및 권장안을 제시합니다."), + ("/context", "현재 목표/제약/다음 액션을 정리합니다."), + ("/session", "세션 핵심 맥락과 다음 할 일을 요약합니다."), + ("/usage", "사용 효율을 높이는 팁을 제시합니다."), + ("/rename", "현재 대화 이름을 즉시 변경합니다."), + ("/feedback", "마지막 응답에 대한 수정 피드백 입력 패널을 엽니다."), + ("/skills", "스킬 시스템/브라우저를 엽니다."), + ("/sandbox-toggle", "권한 모드를 순환 전환합니다."), + ("/statusline", "현재 상태를 한 줄로 요약해 표시합니다."), + ("/heapdump", "메모리 사용 현황을 진단합니다."), + ("/passes", "반복/패스 관련 설정 프리셋을 순환 전환합니다."), + ("/chrome", "인자 없으면 진단, 인자 있으면 브라우저 작업 실행 경로로 라우팅합니다."), + ("/stickers", "빠른 상태 스티커 세트를 보여줍니다."), + ("/thinkback", "최근 대화 맥락을 요약 회고합니다."), + ("/thinkback-play", "회고 내용을 바탕으로 다음 실행 플랜을 제시합니다."), + ("/theme", "테마/표현 관련 설정으로 이동합니다."), + ("/output-style", "출력 스타일 가이드를 제시합니다."), + ("/keybindings", "단축키 효율화 팁을 제시합니다."), + ("/privacy-settings", "보안/개인정보 관점 설정 점검표를 제시합니다."), + ("/rate-limit-options", "요청 한도 대응 전략을 제시합니다.")); // 개발 명령어 섹션 - AddHelpSection(contentPanel, "🛠️ 개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg, - ("/review", "Git diff를 분석하여 버그, 성능, 보안 이슈를 찾습니다."), - ("/pr", "변경사항을 PR 설명 형식(Summary, Changes, Test Plan)으로 요약합니다."), - ("/test", "코드에 대한 단위 테스트를 자동 생성합니다."), - ("/structure", "프로젝트의 폴더/파일 구조를 분석하고 설명합니다."), - ("/build", "프로젝트를 빌드합니다. 오류 발생 시 분석합니다."), - ("/search", "자연어로 코드베이스를 시맨틱 검색합니다.")); + AddHelpSection(contentPanel, "개발 명령어", "Cowork, Code 탭에서만 사용 가능", fg, fg2, accent, itemBg, hoverBg, + ("/review", "변경 코드를 리뷰하고 리스크를 찾습니다."), + ("/commit", "변경사항을 확인하고 승인 후 실제 커밋을 실행합니다. (files: 지정 지원)"), + ("/ultrareview", "더 엄격한 리뷰 기준으로 치명 리스크를 우선 점검합니다."), + ("/security-review", "보안 중심으로 취약점과 개선안을 점검합니다."), + ("/pr", "변경사항을 PR 설명 형식으로 정리합니다."), + ("/pr-comments", "리뷰 코멘트 형태의 개선 의견을 작성합니다."), + ("/test", "테스트 생성/개선 방향을 제시합니다."), + ("/verify", "빌드/테스트/리스크 점검까지 포함한 검증 모드로 실행합니다."), + ("/structure", "프로젝트 구조를 분석합니다."), + ("/build", "빌드 및 오류 분석을 진행합니다."), + ("/search", "코드베이스 검색을 수행합니다."), + ("/diff", "현재 diff의 핵심 변경점/리스크를 요약합니다."), + ("/doctor", "프로젝트/환경 점검 체크를 수행합니다.")); + + AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg, + ("/mcp", "외부 도구 연결 상태 점검"), + ("/agents", "에이전트 분담 전략 제시"), + ("/plugin", "플러그인 구성 점검"), + ("/reload-plugins", "플러그인 재로드 점검"), + ("/install-github-app", "GitHub 앱 연동 안내"), + ("/install-slack-app", "Slack 앱 연동 안내"), + ("/remote-env", "원격 환경 연결 점검"), + ("/ide", "IDE 연동 점검"), + ("/terminal-setup", "터미널 초기 구성 점검")); // 스킬 명령어 섹션 var skills = SkillService.Skills; if (skills.Count > 0) { var skillItems = skills.Select(s => ($"/{s.Name}", s.Description)).ToArray(); - AddHelpSection(contentPanel, "⚡ 스킬 명령어", $"{skills.Count}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems); + AddHelpSection(contentPanel, "스킬 명령어", $"{skills.Count}개 로드됨 — %APPDATA%\\AxCopilot\\skills\\에서 추가 가능", fg, fg2, accent, itemBg, hoverBg, skillItems); } // 사용 팁 contentPanel.Children.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromArgb(30, 255, 255, 255)), Margin = new Thickness(0, 12, 0, 12) }); var tipPanel = new StackPanel(); - tipPanel.Children.Add(new TextBlock { Text = "💡 사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) }); + tipPanel.Children.Add(new TextBlock { Text = "사용 팁", FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = fg, Margin = new Thickness(0, 0, 0, 8) }); var tips = new[] { "/ 입력 시 현재 탭에 맞는 명령어만 자동완성됩니다.", @@ -5135,6 +5598,890 @@ public partial class ChatWindow : Window } } + private async Task ExecuteManualCompactAsync(string commandText, string runTab) + { + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) + _currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab }; + conv = _currentConversation; + } + + var userMsg = new ChatMessage { Role = "user", Content = commandText }; + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(runTab, userMsg, useForTitle: true); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(userMsg); + } + } + + SaveLastConversations(); + _storage.Save(conv); + ChatSession?.RememberConversation(runTab, conv.Id); + UpdateChatTitle(); + AddMessageBubble("user", commandText); + InputBox.Text = ""; + EmptyState.Visibility = Visibility.Collapsed; + ForceScrollToEnd(); + + var llm = _settings.Settings.Llm; + var beforeTokens = Services.TokenEstimator.EstimateMessages(conv.Messages); + var working = conv.Messages.ToList(); + var condensed = await ContextCondenser.CondenseIfNeededAsync( + working, + _llm, + llm.MaxContextTokens, + llm.EnableProactiveContextCompact, + llm.ContextCompactTriggerPercent, + true, + CancellationToken.None); + var afterTokens = Services.TokenEstimator.EstimateMessages(working); + + if (condensed) + { + lock (_convLock) + { + conv.Messages = working; + } + } + + var assistantText = condensed + ? $"컨텍스트 압축을 수행했습니다. 입력 토큰 추정치: {beforeTokens:N0} → {afterTokens:N0}" + : "현재 대화는 압축할 충분한 이전 컨텍스트가 없어 변경 없이 유지했습니다."; + + var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText }; + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(runTab, assistantMsg); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(assistantMsg); + } + } + + SaveLastConversations(); + _storage.Save(conv); + AddMessageBubble("assistant", assistantText); + ForceScrollToEnd(); + SetStatus(condensed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false); + } + + private void AppendLocalSlashResult(string runTab, string commandText, string assistantText) + { + ChatConversation conv; + lock (_convLock) + { + if (_currentConversation == null) + _currentConversation = ChatSession?.EnsureCurrentConversation(runTab) ?? new ChatConversation { Tab = runTab }; + conv = _currentConversation; + } + + var userMsg = new ChatMessage { Role = "user", Content = commandText }; + var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantText }; + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + session.AppendMessage(runTab, userMsg, useForTitle: true); + session.AppendMessage(runTab, assistantMsg); + _currentConversation = session.CurrentConversation; + conv = _currentConversation!; + } + else + { + conv.Messages.Add(userMsg); + conv.Messages.Add(assistantMsg); + } + } + + SaveLastConversations(); + _storage.Save(conv); + ChatSession?.RememberConversation(runTab, conv.Id); + UpdateChatTitle(); + AddMessageBubble("user", commandText); + AddMessageBubble("assistant", assistantText); + InputBox.Text = ""; + EmptyState.Visibility = Visibility.Collapsed; + ForceScrollToEnd(); + } + + private string BuildSlashStatusText() + { + var llm = _settings.Settings.Llm; + var tab = _activeTab; + var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission); + var service = llm.Service?.ToLowerInvariant() switch + { + "gemini" => "Gemini", + "sigmoid" or "claude" => "Claude", + "vllm" => "vLLM", + _ => "Ollama", + }; + var model = GetCurrentModelDisplayName(); + var folder = GetCurrentWorkFolder(); + var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder; + return + $"현재 상태\n" + + $"- 탭: {tab}\n" + + $"- 모델: {service} · {model}\n" + + $"- 권한: {permission}\n" + + $"- 작업 폴더: {folderText}\n" + + $"- 스트리밍: {(llm.Streaming ? "ON" : "OFF")}\n" + + $"- 컨텍스트 토큰: {llm.MaxContextTokens:N0}"; + } + + private string BuildSlashStatsText() + { + var usage = _llm.LastTokenUsage; + if (usage == null) + return "최근 호출 토큰 통계가 아직 없습니다. 대화를 한 번 실행한 뒤 다시 시도하세요."; + return + $"최근 호출 토큰\n" + + $"- 입력: {usage.PromptTokens:N0}\n" + + $"- 출력: {usage.CompletionTokens:N0}\n" + + $"- 합계: {usage.TotalTokens:N0}"; + } + + private string BuildSlashCostText() + { + var usage = _llm.LastTokenUsage; + if (usage == null) + return "최근 호출 토큰 정보가 없어 비용을 계산할 수 없습니다."; + var (inCost, outCost) = Services.TokenEstimator.EstimateCost( + usage.PromptTokens, + usage.CompletionTokens, + _settings.Settings.Llm.Service, + GetCurrentModelDisplayName()); + var total = inCost + outCost; + return + $"최근 호출 추정 비용\n" + + $"- 입력 비용: {Services.TokenEstimator.FormatCost(inCost)}\n" + + $"- 출력 비용: {Services.TokenEstimator.FormatCost(outCost)}\n" + + $"- 합계: {Services.TokenEstimator.FormatCost(total)}"; + } + + private string BuildSlashStatuslineText() + { + var llm = _settings.Settings.Llm; + var (_, model) = _llm.GetCurrentModelInfo(); + var permission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission); + var folder = GetCurrentWorkFolder(); + var folderName = string.IsNullOrWhiteSpace(folder) ? "NoFolder" : System.IO.Path.GetFileName(folder.TrimEnd('\\', '/')); + var historyCount = _currentConversation?.Messages.Count ?? 0; + return $"[{_activeTab}] {ServiceLabel(llm.Service)}/{model} · {permission} · msg {historyCount} · folder {folderName}"; + } + + private bool IsMcpServerEnabled(McpServerEntry server) + { + if (_sessionMcpEnabledOverrides.TryGetValue(server.Name ?? "", out var overridden)) + return overridden; + return server.Enabled; + } + + private bool IsChromeMcpCandidate(McpServerEntry server) + { + if (!IsMcpServerEnabled(server)) + return false; + + return + (server.Name?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 || + (server.Command?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 || + (server.Url?.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 || + server.Args.Any(a => a.IndexOf("chrome", StringComparison.OrdinalIgnoreCase) >= 0); + } + + private async Task BuildChromeRuntimeDiagnosisAsync(CancellationToken ct = default) + { + var servers = _settings.Settings.Llm.McpServers ?? []; + var matches = servers.Where(IsChromeMcpCandidate).ToList(); + if (matches.Count == 0) + return "Chrome MCP가 구성되지 않았습니다. 설정에서 MCP 서버를 추가/활성화한 뒤 다시 /chrome 를 실행하세요."; + + var lines = new List + { + "Chrome MCP 런타임 진단", + $"- 후보 서버: {matches.Count}개" + }; + + var connectedCount = 0; + var totalTools = 0; + foreach (var server in matches) + { + var serverName = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name; + var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant(); + if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) + { + lines.Add($"- {serverName}: {transport} 전송은 현재 앱의 런타임 직접 진단 미지원 (구성만 확인)"); + continue; + } + + using var client = new McpClientService(server); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); + + var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + if (!connected) + { + lines.Add($"- {serverName}: 연결 실패"); + continue; + } + + connectedCount++; + var tools = client.Tools; + totalTools += tools.Count; + lines.Add($"- {serverName}: 연결 성공 (도구 {tools.Count}개)"); + + var toolPreview = tools + .Select(t => t.Name) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Take(6) + .ToList(); + if (toolPreview.Count > 0) + lines.Add($" 도구: {string.Join(", ", toolPreview)}"); + } + + if (connectedCount == 0) + { + lines.Add("진단 결과: 연결 가능한 Chrome MCP 서버가 없습니다."); + lines.Add("확인 항목: command/args 경로, 실행 권한, Node/NPM 설치 상태, 서버 실행 로그"); + return string.Join("\n", lines); + } + + lines.Add($"진단 결과: {connectedCount}개 서버 연결 성공, 총 도구 {totalTools}개 확인"); + lines.Add("다음 단계: /mcp 또는 /mcp reconnect <서버명> 으로 상태를 갱신하세요."); + return string.Join("\n", lines); + } + + private async Task<(bool Ready, List ServerNames, List ToolNames)> ProbeChromeToolingAsync(CancellationToken ct = default) + { + var servers = _settings.Settings.Llm.McpServers ?? []; + var matches = servers.Where(IsChromeMcpCandidate).ToList(); + var readyServers = new List(); + var toolNames = new List(); + if (matches.Count == 0) + return (false, readyServers, toolNames); + + foreach (var server in matches) + { + var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant(); + if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) + continue; + + using var client = new McpClientService(server); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); + var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + if (!connected || client.Tools.Count == 0) + continue; + + readyServers.Add(string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name); + toolNames.AddRange(client.Tools.Select(t => $"mcp_{t.ServerName}_{t.Name}")); + } + + return (readyServers.Count > 0, readyServers, toolNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList()); + } + + internal static string BuildChromeExecutionSystemPrompt(string userRequest, IEnumerable serverNames, IEnumerable toolNames) + { + var serversText = string.Join(", ", serverNames.Take(6)); + var toolList = toolNames.Take(16).ToList(); + var sb = new System.Text.StringBuilder(); + sb.AppendLine("You are executing a browser automation task via MCP tools."); + sb.AppendLine($"Target request: {userRequest}"); + sb.AppendLine($"Preferred MCP servers: {serversText}"); + if (toolList.Count > 0) + sb.AppendLine($"Preferred tools: {string.Join(", ", toolList)}"); + sb.AppendLine("Rules:"); + sb.AppendLine("1) Prioritize the preferred MCP browser tools first."); + sb.AppendLine("2) If URL scheme is missing, default to https://."); + sb.AppendLine("3) Execute only the minimum required steps."); + sb.AppendLine("4) Return concise evidence (visited URL, key page text, or action result)."); + sb.AppendLine("5) If browser tooling is unavailable, explain blocker and suggest /chrome or /mcp reconnect."); + return sb.ToString(); + } + + internal static string BuildVerifySystemPrompt(string request) + { + var objective = string.IsNullOrWhiteSpace(request) || string.Equals(request.Trim(), "/verify", StringComparison.OrdinalIgnoreCase) + ? "현재 변경사항의 품질 검증을 수행하세요." + : request.Trim(); + return + "You are in verification mode.\n" + + $"Verification target: {objective}\n" + + "Required flow:\n" + + "1) Inspect current changes and identify risky files.\n" + + "2) Run build and tests with appropriate tools.\n" + + "3) If failures occur, identify root cause and patch minimally.\n" + + "4) Re-run verification until pass or clear blocker.\n" + + "5) Return a structured report in Korean exactly with sections:\n" + + " [검증결과] PASS|FAIL\n" + + " [실행요약] (build/test/review 실행 내역)\n" + + " [변경파일] (수정한 파일 목록)\n" + + " [잔여리스크] (남은 위험 또는 없음)\n"; + } + + internal static (string action, string argument) ParseGenericAction(string displayText, string command) + { + var raw = (displayText ?? "").Trim(); + if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, command, StringComparison.OrdinalIgnoreCase)) + return ("open", ""); + var parts = raw.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length <= 1) + return (parts[0].Trim().ToLowerInvariant(), ""); + return (parts[0].Trim().ToLowerInvariant(), string.Join(' ', parts.Skip(1)).Trim()); + } + + internal static (List SelectedFiles, string CommitMessage) ParseCommitCommandInput(string displayText) + { + var raw = (displayText ?? "").Trim(); + if (string.IsNullOrWhiteSpace(raw) || string.Equals(raw, "/commit", StringComparison.OrdinalIgnoreCase)) + return (new List(), ""); + + if (!raw.StartsWith("files:", StringComparison.OrdinalIgnoreCase)) + return (new List(), raw); + + var body = raw["files:".Length..].Trim(); + var split = body.Split(new[] { "::" }, 2, StringSplitOptions.None); + var filesPart = split[0].Trim(); + var msgPart = split.Length >= 2 ? split[1].Trim() : ""; + + var files = filesPart + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + return (files, msgPart); + } + + private static string? FindGitExecutablePath() + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo("where.exe", "git") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) + return null; + var output = proc.StandardOutput.ReadToEnd().Trim(); + proc.WaitForExit(5000); + return string.IsNullOrWhiteSpace(output) ? null : output.Split('\n')[0].Trim(); + } + catch + { + return null; + } + } + + private static string? ResolveGitRoot(string? workFolder) + { + if (string.IsNullOrWhiteSpace(workFolder) || !System.IO.Directory.Exists(workFolder)) + return null; + var dir = new System.IO.DirectoryInfo(workFolder); + while (dir != null) + { + if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git"))) + return dir.FullName; + dir = dir.Parent; + } + + return null; + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunGitAsync( + string gitPath, + string workDir, + IEnumerable args, + CancellationToken ct = default) + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = gitPath, + WorkingDirectory = workDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = System.Text.Encoding.UTF8, + StandardErrorEncoding = System.Text.Encoding.UTF8, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) + return (-1, "", "git 프로세스를 시작하지 못했습니다."); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + var stdoutTask = proc.StandardOutput.ReadToEndAsync(timeoutCts.Token); + var stderrTask = proc.StandardError.ReadToEndAsync(timeoutCts.Token); + await proc.WaitForExitAsync(timeoutCts.Token); + var stdout = await stdoutTask; + var stderr = await stderrTask; + return (proc.ExitCode, stdout, stderr); + } + + private async Task ExecuteCommitWithApprovalAsync(string? displayText, CancellationToken ct = default) + { + if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + return "커밋 실행은 Cowork/Code 탭에서만 지원됩니다."; + } + + var folder = GetCurrentWorkFolder(); + var gitRoot = ResolveGitRoot(folder); + if (string.IsNullOrWhiteSpace(gitRoot)) + return "Git 저장소를 찾지 못했습니다. 작업 폴더를 Git 프로젝트로 설정해 주세요."; + + var gitPath = FindGitExecutablePath(); + if (string.IsNullOrWhiteSpace(gitPath)) + return "Git 실행 파일을 찾지 못했습니다. Git 설치 및 PATH를 확인해 주세요."; + + var status = await RunGitAsync(gitPath, gitRoot, new[] { "status", "--porcelain" }, ct).ConfigureAwait(false); + if (status.ExitCode != 0) + return $"git status 실패:\n{status.StdErr.Trim()}"; + + var changedLines = status.StdOut + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + if (changedLines.Count == 0) + return "커밋할 변경사항이 없습니다."; + + var (selectedFilesRaw, parsedMessage) = ParseCommitCommandInput(displayText ?? ""); + var changedPathSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var line in changedLines) + { + var path = line.Length > 3 ? line[3..].Trim() : line.Trim(); + if (string.IsNullOrWhiteSpace(path)) + continue; + if (path.Contains("->", StringComparison.Ordinal)) + { + var renameParts = path.Split(new[] { "->" }, 2, StringSplitOptions.None); + if (renameParts.Length == 2) + { + var before = renameParts[0].Trim(); + var after = renameParts[1].Trim(); + if (!string.IsNullOrWhiteSpace(before)) changedPathSet.Add(before); + if (!string.IsNullOrWhiteSpace(after)) changedPathSet.Add(after); + continue; + } + } + + changedPathSet.Add(path); + } + + var selectedFiles = selectedFilesRaw.Count == 0 + ? changedPathSet.ToList() + : selectedFilesRaw + .Where(p => changedPathSet.Contains(p)) + .ToList(); + + if (selectedFiles.Count == 0) + { + if (selectedFilesRaw.Count > 0) + return "지정한 파일이 현재 변경 목록에 없습니다. /commit files:<경로1,경로2> :: <메시지> 형식을 확인해 주세요."; + return "커밋할 변경 파일을 찾지 못했습니다."; + } + + var commitMessage = string.IsNullOrWhiteSpace(parsedMessage) + ? $"작업: 변경사항 반영 ({selectedFiles.Count}개 파일)" + : parsedMessage; + + var previewFiles = selectedFiles + .Take(8) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + var previewText = previewFiles.Count == 0 + ? "(파일 목록 없음)" + : string.Join("\n", previewFiles.Select(f => $"- {f}")); + + var confirm = CustomMessageBox.Show( + $"다음 내용으로 커밋을 진행할까요?\n\n저장소: {gitRoot}\n메시지: {commitMessage}\n\n커밋 대상 파일(일부):\n{previewText}", + "커밋 승인", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + if (confirm != MessageBoxResult.Yes) + return "커밋 실행을 취소했습니다."; + + var addArgs = new List { "add" }; + if (selectedFilesRaw.Count == 0) + { + addArgs.Add("-A"); + } + else + { + addArgs.Add("--"); + addArgs.AddRange(selectedFiles); + } + + var add = await RunGitAsync(gitPath, gitRoot, addArgs, ct).ConfigureAwait(false); + if (add.ExitCode != 0) + return $"git add 실패:\n{add.StdErr.Trim()}"; + + var commit = await RunGitAsync(gitPath, gitRoot, new[] { "commit", "-m", commitMessage }, ct).ConfigureAwait(false); + if (commit.ExitCode != 0) + { + var err = string.IsNullOrWhiteSpace(commit.StdErr) ? commit.StdOut : commit.StdErr; + return $"git commit 실패:\n{err.Trim()}"; + } + + var head = await RunGitAsync(gitPath, gitRoot, new[] { "log", "--oneline", "-1" }, ct).ConfigureAwait(false); + var headLine = (head.StdOut ?? "").Trim(); + return + $"커밋이 완료되었습니다.\n" + + $"- 메시지: {commitMessage}\n" + + $"- 커밋 파일 수: {selectedFiles.Count}\n" + + (string.IsNullOrWhiteSpace(headLine) ? "" : $"- HEAD: {headLine}\n") + + "- 원격 반영은 사용자가 직접 push 해주세요.\n" + + "- 팁: /commit files:path1,path2 :: 메시지 형태로 부분 커밋할 수 있습니다."; + } + + internal static (string action, string target) ParseMcpAction(string displayText) + { + var text = (displayText ?? "").Trim(); + if (string.IsNullOrWhiteSpace(text) || string.Equals(text, "/mcp", StringComparison.OrdinalIgnoreCase)) + return ("status", ""); + + var parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + return ("status", ""); + + var action = parts[0].Trim().ToLowerInvariant(); + var target = parts.Length >= 2 ? string.Join(' ', parts.Skip(1)).Trim() : ""; + return action switch + { + "enable" => ("enable", target), + "disable" => ("disable", target), + "reconnect" => ("reconnect", target), + "status" => ("status", target), + _ => ("help", text), + }; + } + + private async Task BuildMcpRuntimeStatusTextAsync( + IEnumerable? source = null, + bool runtimeCheck = true, + CancellationToken ct = default) + { + var servers = (source ?? _settings.Settings.Llm.McpServers ?? []).ToList(); + if (servers.Count == 0) + return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 /mcp 를 다시 실행하세요."; + + var lines = new List + { + "MCP 상태", + $"- 전체: {servers.Count}개", + $"- 활성(세션 기준): {servers.Count(IsMcpServerEnabled)}개" + }; + + foreach (var server in servers.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)) + { + var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name; + var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant(); + if (!IsMcpServerEnabled(server)) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}"); + continue; + } + + if (!runtimeCheck) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}"); + continue; + } + + if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}"); + continue; + } + + using var client = new McpClientService(server); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); + var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + if (!connected) + { + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}"); + continue; + } + + var toolCount = client.Tools.Count; + var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount); + lines.Add($"- {name} [{transport}] : {statusLabel}"); + } + + lines.Add("명령: /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all> (enable/disable는 세션 한정)"); + return string.Join("\n", lines); + } + + internal static string ResolveMcpDisplayStatus(bool isEnabled, string transport, bool runtimeCheck, bool connected, int? toolCount) + { + var normalizedTransport = string.IsNullOrWhiteSpace(transport) ? "stdio" : transport.Trim().ToLowerInvariant(); + if (!isEnabled) + return "Disabled"; + + if (!runtimeCheck) + return "Enabled"; + + if (!string.Equals(normalizedTransport, "stdio", StringComparison.OrdinalIgnoreCase)) + return "Configured"; + + if (!connected) + return "Disconnected"; + + if ((toolCount ?? 0) <= 0) + return "NeedsAuth (도구 0개)"; + + return $"Connected (도구 {toolCount}개)"; + } + + private string ResolveMcpServerName(IEnumerable servers, string inputName) + { + var trimmed = inputName.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + return ""; + var exact = servers.FirstOrDefault(s => string.Equals(s.Name, trimmed, StringComparison.OrdinalIgnoreCase)); + if (exact != null) + return exact.Name; + var partial = servers.FirstOrDefault(s => (s.Name?.IndexOf(trimmed, StringComparison.OrdinalIgnoreCase) ?? -1) >= 0); + return partial?.Name ?? ""; + } + + private async Task HandleMcpSlashAsync(string displayText, CancellationToken ct = default) + { + var llm = _settings.Settings.Llm; + var servers = llm.McpServers ?? []; + var (action, target) = ParseMcpAction(displayText); + if (action == "help") + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>"; + + if (action == "status") + return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); + + if (servers.Count == 0) + return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요."; + + if (action is "enable" or "disable") + { + var newEnabled = action == "enable"; + int changed; + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + changed = 0; + foreach (var server in servers.Where(s => IsMcpServerEnabled(s) != newEnabled)) + { + _sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled; + changed++; + } + } + else + { + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"대상 서버를 찾지 못했습니다: {target}"; + var server = servers.First(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)); + changed = IsMcpServerEnabled(server) == newEnabled ? 0 : 1; + _sessionMcpEnabledOverrides[server.Name ?? ""] = newEnabled; + } + + var status = newEnabled ? "활성화" : "비활성화"; + return $"MCP 서버 {status} 완료 ({changed}개 변경, 현재 세션에만 적용)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + if (action == "reconnect") + { + List targetServers; + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + targetServers = servers.Where(IsMcpServerEnabled).ToList(); + } + else + { + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"재연결 대상 서버를 찾지 못했습니다: {target}"; + targetServers = servers.Where(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (targetServers.Count == 0) + return "재연결할 활성 MCP 서버가 없습니다."; + + return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false); + } + + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>"; + } + + private string BuildSlashHeapDumpText() + { + var proc = System.Diagnostics.Process.GetCurrentProcess(); + var managedBytes = GC.GetTotalMemory(forceFullCollection: false); + var workingSet = proc.WorkingSet64; + var privateBytes = proc.PrivateMemorySize64; + return + $"메모리 진단 (heapdump)\n" + + $"- Managed Heap: {managedBytes / 1024.0 / 1024.0:F1} MB\n" + + $"- Working Set: {workingSet / 1024.0 / 1024.0:F1} MB\n" + + $"- Private Bytes: {privateBytes / 1024.0 / 1024.0:F1} MB\n" + + $"- GC Gen0/1/2: {GC.CollectionCount(0)}/{GC.CollectionCount(1)}/{GC.CollectionCount(2)}"; + } + + private string CyclePassPreset() + { + var llm = _settings.Settings.Llm; + var current = llm.MaxAgentIterations <= 16 ? 16 : llm.MaxAgentIterations <= 25 ? 25 : 40; + var next = current switch + { + 16 => 25, + 25 => 40, + _ => 16, + }; + llm.MaxAgentIterations = next; + _settings.Save(); + return $"Agent Pass 프리셋을 {next}로 변경했습니다."; + } + + private string BuildThinkbackSummaryText() + { + var conv = _currentConversation; + if (conv == null || conv.Messages.Count == 0) + return "회고할 대화가 없습니다."; + + var recent = conv.Messages + .Where(m => m.Role is "user" or "assistant") + .TakeLast(10) + .ToList(); + if (recent.Count == 0) + return "회고할 대화가 없습니다."; + + var userCount = recent.Count(m => m.Role == "user"); + var assistantCount = recent.Count(m => m.Role == "assistant"); + var latestRun = _appState.GetLatestConversationRun(conv.AgentRunHistory); + var highlights = recent + .TakeLast(4) + .Select(m => $"- {m.Role}: {TruncateForStatus((m.Content ?? "").Replace("\r", " ").Replace("\n", " "), 80)}"); + + return + $"최근 대화 회고\n" + + $"- 최근 메시지: {recent.Count}개 (user {userCount}, assistant {assistantCount})\n" + + $"- 최근 실행 상태: {(latestRun == null ? "기록 없음" : $"{latestRun.Status} · {TruncateForStatus(latestRun.Summary, 40)}")}\n" + + string.Join("\n", highlights); + } + + private string BuildThinkbackPlayText() + { + var conv = _currentConversation; + if (conv == null || conv.Messages.Count == 0) + return "재생할 대화 이력이 없습니다."; + + var lastUser = conv.Messages.LastOrDefault(m => m.Role == "user")?.Content ?? "(없음)"; + var lastAssistant = conv.Messages.LastOrDefault(m => m.Role == "assistant")?.Content ?? "(없음)"; + var folder = GetCurrentWorkFolder(); + var folderText = string.IsNullOrWhiteSpace(folder) ? "(미설정)" : folder; + return + $"회고 기반 실행 플랜\n" + + $"1. 마지막 사용자 요청 재확인: {TruncateForStatus(lastUser.Replace('\n', ' '), 72)}\n" + + $"2. 마지막 응답의 보강 포인트 점검: {TruncateForStatus(lastAssistant.Replace('\n', ' '), 72)}\n" + + $"3. 작업 폴더 기준 실행/검증: {TruncateForStatus(folderText, 52)}\n" + + $"4. 부족한 근거/테스트 보강 후 다음 답변 생성"; + } + + private void OpenSkillsFromSlash() + { + var llm = _settings.Settings.Llm; + if (!llm.EnableSkillSystem) + { + llm.EnableSkillSystem = true; + SkillService.EnsureSkillFolder(); + SkillService.LoadSkills(llm.SkillsFolderPath); + UpdateConditionalSkillActivation(reset: true); + _settings.Save(); + _appState.LoadFromSettings(_settings); + RefreshInlineSettingsPanel(); + } + + OpenCommandSkillBrowser("/"); + } + + private string TogglePermissionModeFromSlash() + { + var llm = _settings.Settings.Llm; + llm.FilePermission = NextPermission(llm.FilePermission); + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + return PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission); + } + + private bool PromptRenameConversationFromSlash(out string renamedTitle) + { + renamedTitle = ""; + ChatConversation? conv; + lock (_convLock) + { + _currentConversation ??= ChatSession?.EnsureCurrentConversation(_activeTab) ?? new ChatConversation { Tab = _activeTab }; + conv = _currentConversation; + } + + var currentTitle = string.IsNullOrWhiteSpace(conv.Title) ? "새 대화" : conv.Title; + var dlg = new Views.InputDialog("대화 이름 변경", "새 대화 이름:", currentTitle) { Owner = this }; + if (dlg.ShowDialog() != true) + return false; + + var newTitle = dlg.ResponseText.Trim(); + if (string.IsNullOrWhiteSpace(newTitle) || string.Equals(newTitle, currentTitle, StringComparison.Ordinal)) + return false; + + lock (_convLock) + { + var session = ChatSession; + if (session != null) + { + _currentConversation = session.UpdateConversationMetadata(_activeTab, c => c.Title = newTitle, _storage); + conv = _currentConversation; + } + else if (conv != null) + { + conv.Title = newTitle; + _storage.Save(conv); + } + } + + SaveLastConversations(); + UpdateChatTitle(); + RefreshConversationList(); + renamedTitle = newTitle; + return true; + } + private async Task SendMessageAsync() { var rawText = InputBox.Text.Trim(); @@ -5153,6 +6500,283 @@ public partial class ChatWindow : Window // 슬래시 명령어 처리 var (slashSystem, displayText) = ParseSlashCommand(text); + if (string.Equals(slashSystem, "__CLEAR__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/clear", "현재 대화를 정리하고 새 대화를 시작합니다."); + StartNewConversation(); + return; + } + if (string.Equals(slashSystem, "__NEW__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/new", "새 대화를 시작합니다."); + StartNewConversation(); + return; + } + if (string.Equals(slashSystem, "__RESET__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/reset", "현재 세션 컨텍스트를 초기화하고 새 대화를 시작합니다."); + StartNewConversation(); + return; + } + if (string.Equals(slashSystem, "__STATUS__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText()); + return; + } + if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal)) + { + var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions"); + if (permAction is "ask" or "auto" or "deny") + { + var next = permAction switch + { + "ask" => PermissionModeCatalog.Ask, + "auto" => PermissionModeCatalog.Auto, + _ => PermissionModeCatalog.Deny, + }; + _settings.Settings.Llm.FilePermission = next; + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {next}로 변경했습니다."); + return; + } + + if (permAction == "status") + { + var mode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + AppendLocalSlashResult(_activeTab, "/permissions", $"현재 권한 모드: {mode}\n사용법: /permissions ask|auto|deny|status"); + return; + } + + BtnPermission_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/permissions", "권한 설정 팝업을 열었습니다. (사용법: /permissions ask|auto|deny|status)"); + return; + } + if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal)) + { + BtnPermission_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/allowed-tools", "허용 도구(권한) 설정 팝업을 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal)) + { + BtnModelSelector_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/model", "모델 선택 패널을 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__SETTINGS__", StringComparison.Ordinal)) + { + var (settingsAction, _) = ParseGenericAction(displayText ?? "", "/settings"); + if (settingsAction == "model") + { + BtnModelSelector_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/settings", "모델 선택 패널을 열었습니다."); + return; + } + + if (settingsAction == "permissions") + { + BtnPermission_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, "/settings", "권한 설정 팝업을 열었습니다."); + return; + } + + if (settingsAction == "mcp") + { + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. MCP 항목에서 서버를 관리하세요."); + return; + } + + if (settingsAction == "theme") + { + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. 테마 항목을 확인하세요."); + return; + } + + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/settings", "AX Agent 설정 창을 열었습니다. (사용법: /settings model|permissions|mcp|theme)"); + return; + } + if (string.Equals(slashSystem, "__THEME__", StringComparison.Ordinal)) + { + OpenAgentSettingsWindow(); + AppendLocalSlashResult(_activeTab, "/theme", "설정 창을 열었습니다. AX Agent 테마를 변경할 수 있습니다."); + return; + } + if (string.Equals(slashSystem, "__STATS__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/stats", BuildSlashStatsText()); + return; + } + if (string.Equals(slashSystem, "__COST__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/cost", BuildSlashCostText()); + return; + } + if (string.Equals(slashSystem, "__EXPORT__", StringComparison.Ordinal)) + { + ExportConversation(); + AppendLocalSlashResult(_activeTab, "/export", "현재 대화를 내보냈습니다."); + return; + } + if (string.Equals(slashSystem, "__STATUSLINE__", StringComparison.Ordinal)) + { + var line = BuildSlashStatuslineText(); + SetStatus(line, spinning: false); + AppendLocalSlashResult(_activeTab, "/statusline", line); + return; + } + if (string.Equals(slashSystem, "__HEAPDUMP__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/heapdump", BuildSlashHeapDumpText()); + return; + } + if (string.Equals(slashSystem, "__PASSES__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/passes", CyclePassPreset()); + return; + } + if (string.Equals(slashSystem, "__CHROME__", StringComparison.Ordinal)) + { + var chromeInput = (displayText ?? "").Trim(); + var hasArgs = !string.IsNullOrWhiteSpace(chromeInput) + && !string.Equals(chromeInput, "/chrome", StringComparison.OrdinalIgnoreCase); + if (!hasArgs) + { + var diagnosis = await BuildChromeRuntimeDiagnosisAsync(); + AppendLocalSlashResult(_activeTab, "/chrome", diagnosis); + return; + } + + if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + AppendLocalSlashResult(_activeTab, "/chrome", + "브라우저 제어 실행은 Cowork/Code 탭에서 지원됩니다. 탭 전환 후 다시 실행해 주세요.\n" + + $"예: /chrome {chromeInput}"); + return; + } + + var probe = await ProbeChromeToolingAsync(); + if (!probe.Ready) + { + var reconnectResult = await HandleMcpSlashAsync("/mcp reconnect all"); + probe = await ProbeChromeToolingAsync(); + if (probe.Ready) + { + slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames); + text = chromeInput; + SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비(재연결 성공)", spinning: false); + AppendLocalSlashResult(_activeTab, "/chrome", "사전 점검에서 연결이 부족해 /mcp reconnect all을 자동 실행했고 재시도에 성공했습니다."); + goto CHROME_ROUTING_READY; + } + + var diagnosis = await BuildChromeRuntimeDiagnosisAsync(); + AppendLocalSlashResult(_activeTab, "/chrome", + "브라우저 MCP 도구가 준비되지 않아 실행을 시작하지 못했습니다.\n" + + "자동 재시도(/mcp reconnect all) 결과:\n" + reconnectResult + "\n\n" + diagnosis); + return; + } + + slashSystem = BuildChromeExecutionSystemPrompt(chromeInput, probe.ServerNames, probe.ToolNames); + text = chromeInput; + SetStatus($"Chrome 실행 라우팅: {probe.ServerNames.Count}개 서버 준비", spinning: false); + CHROME_ROUTING_READY:; + } + if (string.Equals(slashSystem, "__MCP__", StringComparison.Ordinal)) + { + var mcpResult = await HandleMcpSlashAsync(displayText ?? ""); + AppendLocalSlashResult(_activeTab, "/mcp", mcpResult); + return; + } + if (string.Equals(slashSystem, "__STICKERS__", StringComparison.Ordinal)) + { + var queueSummary = _appState.GetDraftQueueSummary(_activeTab); + var activeCount = _appState.ActiveTasks.Count; + AppendLocalSlashResult(_activeTab, "/stickers", + "빠른 상태 스티커\n" + + $"- [RUN] 진행중 작업 {activeCount}\n" + + $"- [QUEUE] 대기 {queueSummary.QueuedCount}\n" + + $"- [BLOCK] 승인 대기 {queueSummary.BlockedCount}\n" + + "- [DONE] 완료 후 /statusline 으로 상태 확인"); + return; + } + if (string.Equals(slashSystem, "__THINKBACK__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/thinkback", BuildThinkbackSummaryText()); + return; + } + if (string.Equals(slashSystem, "__THINKBACK_PLAY__", StringComparison.Ordinal)) + { + AppendLocalSlashResult(_activeTab, "/thinkback-play", BuildThinkbackPlayText()); + return; + } + if (string.Equals(slashSystem, "__SKILLS__", StringComparison.Ordinal)) + { + OpenSkillsFromSlash(); + AppendLocalSlashResult(_activeTab, "/skills", "스킬 브라우저를 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__SANDBOX_TOGGLE__", StringComparison.Ordinal)) + { + var mode = TogglePermissionModeFromSlash(); + AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {mode}로 변경했습니다."); + return; + } + if (string.Equals(slashSystem, "__RENAME__", StringComparison.Ordinal)) + { + if (PromptRenameConversationFromSlash(out var renamedTitle)) + AppendLocalSlashResult(_activeTab, "/rename", $"대화 이름을 \"{renamedTitle}\"로 변경했습니다."); + else + AppendLocalSlashResult(_activeTab, "/rename", "이름 변경을 취소했습니다."); + return; + } + if (string.Equals(slashSystem, "__FEEDBACK__", StringComparison.Ordinal)) + { + ChatConversation? currentConv; + lock (_convLock) + currentConv = _currentConversation; + var hasAssistant = currentConv?.Messages.Any(m => m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content)) == true; + if (!hasAssistant) + { + AppendLocalSlashResult(_activeTab, "/feedback", "피드백할 이전 응답이 없습니다. 먼저 대화를 진행한 뒤 다시 시도하세요."); + return; + } + + ShowRetryWithFeedbackInput(); + AppendLocalSlashResult(_activeTab, "/feedback", "수정 피드백 입력 패널을 열었습니다."); + return; + } + if (string.Equals(slashSystem, "__VERIFY__", StringComparison.Ordinal)) + { + if (!string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase)) + { + AppendLocalSlashResult(_activeTab, "/verify", "검증 모드는 Cowork/Code 탭에서만 실행할 수 있습니다."); + return; + } + + slashSystem = BuildVerifySystemPrompt(displayText ?? ""); + text = string.IsNullOrWhiteSpace(displayText) ? "현재 변경사항 검증 실행" : displayText; + SetStatus("검증 모드 실행 준비...", spinning: false); + } + if (string.Equals(slashSystem, "__COMMIT__", StringComparison.Ordinal)) + { + var commitResult = await ExecuteCommitWithApprovalAsync(displayText, _streamCts?.Token ?? CancellationToken.None); + AppendLocalSlashResult(_activeTab, "/commit", commitResult); + return; + } + if (string.Equals(slashSystem, "__COMPACT__", StringComparison.Ordinal)) + { + await ExecuteManualCompactAsync("/compact", _activeTab); + return; + } + // 탭 전환 시에도 올바른 탭에 저장하기 위해 시작 시점의 탭을 캡처 var originTab = _activeTab; var runTab = originTab; @@ -5310,6 +6934,21 @@ public partial class ChatWindow : Window if (_attachedFiles.Count == 0) AttachedFilesPanel.Visibility = Visibility.Collapsed; } + // ── 전송 전 컨텍스트 사전 압축 ── + { + var llm = _settings.Settings.Llm; + var condensed = await ContextCondenser.CondenseIfNeededAsync( + sendMessages, + _llm, + llm.MaxContextTokens, + llm.EnableProactiveContextCompact, + llm.ContextCompactTriggerPercent, + false, + _streamCts!.Token); + if (condensed) + SetStatus("컨텍스트를 사전 정리했습니다", spinning: true); + } + // ── 자동 모델 라우팅 ── if (_settings.Settings.Llm.EnableAutoRouter) { @@ -6084,8 +7723,8 @@ public partial class ChatWindow : Window private void ApplyConversationListPreferences(ChatConversation? conv) { - _failedOnlyFilter = conv?.ConversationFailedOnlyFilter ?? false; - _runningOnlyFilter = conv?.ConversationRunningOnlyFilter ?? false; + _failedOnlyFilter = false; + _runningOnlyFilter = false; _sortConversationsByRecent = string.Equals(conv?.ConversationSortMode, "recent", StringComparison.OrdinalIgnoreCase); UpdateConversationFailureFilterUi(); UpdateConversationRunningFilterUi(); @@ -6414,19 +8053,93 @@ public partial class ChatWindow : Window /// 계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다. private void AddDecisionButtons(TaskCompletionSource tcs, List options) { + var expressionLevel = GetAgentUiExpressionLevel(); + var showDetailedCopy = expressionLevel != "simple"; + var showRichHint = expressionLevel == "rich"; + var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue; var accentColor = ((SolidColorBrush)accentBrush).Color; var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var itemBg = TryFindResource("ItemBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)); + var hoverBg = TryFindResource("ItemHoverBackground") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + var borderBrush = TryFindResource("BorderColor") as Brush + ?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B)); var container = new Border { - Margin = new Thickness(40, 2, 80, 6), + Margin = expressionLevel == "simple" + ? new Thickness(40, 2, 120, 6) + : new Thickness(40, 2, 80, 6), HorizontalAlignment = HorizontalAlignment.Left, - MaxWidth = 560, + MaxWidth = expressionLevel == "simple" ? 460 : 560, + Background = itemBg, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + Padding = new Thickness(12, 10, 12, 10), }; var outerStack = new StackPanel(); + outerStack.Children.Add(new TextBlock + { + Text = "실행 계획 승인 요청", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = primaryText, + }); + if (showDetailedCopy) + { + outerStack.Children.Add(new TextBlock + { + Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.", + FontSize = 11.5, + Foreground = secondaryText, + Margin = new Thickness(0, 2, 0, 8), + TextWrapping = TextWrapping.Wrap, + }); + } + + if (showDetailedCopy && options.Count > 0) + { + var optionCandidates = new List(); + foreach (var option in options) + { + if (string.IsNullOrWhiteSpace(option)) + continue; + + optionCandidates.Add(option.Trim()); + if (optionCandidates.Count >= 3) + break; + } + var optionHint = string.Join(" · ", optionCandidates); + if (!string.IsNullOrWhiteSpace(optionHint)) + { + outerStack.Children.Add(new TextBlock + { + Text = $"선택지: {optionHint}", + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 8), + TextWrapping = TextWrapping.Wrap, + }); + } + } + if (showRichHint) + { + outerStack.Children.Add(new TextBlock + { + Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.", + FontSize = 11, + Foreground = secondaryText, + Margin = new Thickness(0, 0, 0, 8), + TextWrapping = TextWrapping.Wrap, + }); + } + // 버튼 행 var btnRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 0) }; @@ -6445,7 +8158,13 @@ public partial class ChatWindow : Window Text = "\uE73E", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), }); - approveSp.Children.Add(new TextBlock { Text = "승인", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }); + approveSp.Children.Add(new TextBlock + { + Text = "승인 후 실행", + FontSize = 12.5, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + }); approveBtn.Child = approveSp; ApplyMenuItemHover(approveBtn); approveBtn.MouseLeftButtonUp += (_, _) => @@ -6458,7 +8177,7 @@ public partial class ChatWindow : Window // 수정 요청 버튼 var editBtn = new Border { - Background = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B)), + Background = Brushes.Transparent, CornerRadius = new CornerRadius(16), Padding = new Thickness(14, 7, 14, 7), Margin = new Thickness(0, 0, 8, 0), @@ -6472,7 +8191,7 @@ public partial class ChatWindow : Window Text = "\uE70F", FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 11, Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0), }); - editSp.Children.Add(new TextBlock { Text = "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); + editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush }); editBtn.Child = editSp; ApplyMenuItemHover(editBtn); @@ -6480,7 +8199,9 @@ public partial class ChatWindow : Window var editInputPanel = new Border { Visibility = Visibility.Collapsed, - Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#F8F9FC")), + Background = Brushes.Transparent, + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), Padding = new Thickness(10, 8, 10, 8), Margin = new Thickness(0, 8, 0, 0), @@ -6488,7 +8209,7 @@ public partial class ChatWindow : Window var editInputStack = new StackPanel(); editInputStack.Children.Add(new TextBlock { - Text = "수정 사항을 입력하세요:", + Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:", FontSize = 11.5, Foreground = secondaryText, Margin = new Thickness(0, 0, 0, 6), }); @@ -6499,7 +8220,9 @@ public partial class ChatWindow : Window AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, FontSize = 12.5, - Background = Brushes.White, + Background = itemBg, + Foreground = primaryText, + CaretBrush = primaryText, BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)), BorderThickness = new Thickness(1), Padding = new Thickness(8, 6, 8, 6), @@ -6515,7 +8238,13 @@ public partial class ChatWindow : Window Cursor = Cursors.Hand, HorizontalAlignment = HorizontalAlignment.Right, }; - submitEditBtn.Child = new TextBlock { Text = "전송", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White }; + submitEditBtn.Child = new TextBlock + { + Text = "피드백 전송", + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = Brushes.White + }; ApplyHoverScaleAnimation(submitEditBtn, 1.05); submitEditBtn.MouseLeftButtonUp += (_, _) => { @@ -6663,6 +8392,12 @@ public partial class ChatWindow : Window { agentDecision = _planViewerWindow?.BuildApprovedDecisionPayload(AgentLoopService.ApprovedPlanDecisionPrefix); } + else if (!string.Equals(result, "취소", StringComparison.OrdinalIgnoreCase) + && !string.Equals(result, "승인", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(result)) + { + agentDecision = $"수정 요청: {result.Trim()}"; + } // 승인된 경우 — 실행 모드로 전환 if (result == null) // null = 승인 @@ -7237,8 +8972,8 @@ public partial class ChatWindow : Window : evt.Type switch { AgentEventType.Thinking => ("\uE8BD", "Thinking", "#F0F0FF", "#6B7BC4"), - AgentEventType.PermissionRequest => ("\uE897", "Permission", "#FFF7ED", "#C2410C"), - AgentEventType.PermissionGranted => ("\uE73E", "Approved", "#ECFDF5", "#059669"), + AgentEventType.PermissionRequest => GetPermissionBadgeMeta(evt.ToolName, pending: true), + AgentEventType.PermissionGranted => GetPermissionBadgeMeta(evt.ToolName, pending: false), AgentEventType.PermissionDenied => ("\uE783", "Denied", "#FEF2F2", "#DC2626"), AgentEventType.Decision => GetDecisionBadgeMeta(evt.Summary), AgentEventType.ToolCall => ("\uE8A7", evt.ToolName, "#EEF6FF", "#3B82F6"), @@ -10163,8 +11898,7 @@ public partial class ChatWindow : Window private void BtnSettings_Click(object sender, RoutedEventArgs e) { - if (System.Windows.Application.Current is App app) - app.OpenSettingsFromChat(); + OpenAgentSettingsWindow(); } // ─── 프롬프트 템플릿 팝업 ──────────────────────────────────────────── @@ -10423,13 +12157,35 @@ public partial class ChatWindow : Window private void BtnModelSelector_Click(object sender, RoutedEventArgs e) { - InlineSettingsPanel.Visibility = InlineSettingsPanel.Visibility == Visibility.Visible - ? Visibility.Collapsed - : Visibility.Visible; - if (InlineSettingsPanel.Visibility == Visibility.Visible) + OpenAgentSettingsWindow(); + } + + private void OpenAgentSettingsWindow() + { + var win = new AgentSettingsWindow(_settings) { - RefreshInlineSettingsPanel(); - } + Owner = this, + }; + win.Resources.MergedDictionaries.Add(Resources); + + var changed = win.ShowDialog() == true; + if (!changed) + return; + + _appState.LoadFromSettings(_settings); + ApplyAgentThemeResources(); + UpdateSidebarModeMenu(); + UpdateModelLabel(); + RefreshInlineSettingsPanel(); + UpdateTabUI(); + ShowToast("AX Agent 설정이 저장되었습니다."); + } + + public void OpenAgentSettingsFromExternal() + { + Show(); + Activate(); + OpenAgentSettingsWindow(); } private void BtnInlineSettingsClose_Click(object sender, RoutedEventArgs e) @@ -10546,9 +12302,9 @@ public partial class ChatWindow : Window { StopStreamingIfActive(); SaveCurrentTabConversationId(); + PersistPerTabUiState(); _activeTab = targetTab; - _selectedCategory = ""; - UpdateCategoryLabel(); + RestorePerTabUiState(); UpdateTabUI(); if (string.Equals(targetTab, "Chat", StringComparison.OrdinalIgnoreCase)) @@ -11858,6 +13614,30 @@ public partial class ChatWindow : Window return ("\uE70F", "Plan Decision", "#FFF7ED", "#C2410C"); } + private static (string icon, string label, string bgHex, string fgHex) GetPermissionBadgeMeta(string? toolName, bool pending) + { + var tool = toolName?.Trim().ToLowerInvariant() ?? ""; + + if (tool.Contains("process") || tool.Contains("bash") || tool.Contains("powershell")) + return pending + ? ("\uE756", "Command Permission", "#FEF2F2", "#DC2626") + : ("\uE73E", "Command Approved", "#ECFDF5", "#059669"); + + if (tool.Contains("web") || tool.Contains("fetch") || tool.Contains("http")) + return pending + ? ("\uE774", "Network Permission", "#FFF7ED", "#C2410C") + : ("\uE73E", "Network Approved", "#ECFDF5", "#059669"); + + if (tool.Contains("file")) + return pending + ? ("\uE8A5", "File Permission", "#FFF7ED", "#C2410C") + : ("\uE73E", "File Approved", "#ECFDF5", "#059669"); + + return pending + ? ("\uE897", "Permission", "#FFF7ED", "#C2410C") + : ("\uE73E", "Approved", "#ECFDF5", "#059669"); + } + private static bool IsDecisionPending(string? summary) { var text = summary?.Trim() ?? "";