diff --git a/docs/AGENT_ROADMAP.md b/docs/AGENT_ROADMAP.md index 3222adc..ef770c2 100644 --- a/docs/AGENT_ROADMAP.md +++ b/docs/AGENT_ROADMAP.md @@ -61,3 +61,5 @@ 4. `additionalContext`는 가능한 경로에서 메시지 컨텍스트로 반영. - 2026-04-04(추가): `/mcp add/remove/reset` 확장, `tool_search` 기반 복구 프롬프트 강화, 슬래시 힌트 밀도(`rich/balanced/simple`) 연동. +- 2026-04-04(추가2): /mcp login/logout 세션 인증 토큰 지원, /mcp status·/chrome 진단에 Auth(Session) 반영. +- 2026-04-04(추가3): 권한 UX 통합(/permissions·/allowed-tools·/settings permissions), 복구 혼합 테스트 보강, 좌측 패널 실패 필터 노출 정책 rich 전용으로 정렬. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6cd503f..4b9545b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -2748,3 +2748,45 @@ else: ### E. 검증 결과 - `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (411 passed, 0 failed). + +## 2026-04-04 추가 진행 기록 (연속 실행 3차) + +### /mcp 인증 명령 추가 +- `/mcp login <서버명> <토큰>`: 세션 인증 토큰 설정. +- `/mcp logout <서버명|all>`: 세션 인증 토큰 제거. +- `/mcp reset` 시 세션 MCP 활성/비활성 오버라이드와 인증 토큰을 함께 초기화. + +### 런타임 반영 범위 +- MCP 상태 점검(`/mcp status`)과 Chrome 진단(`/chrome`)에서 세션 토큰을 `MCP_AUTH_TOKEN` 환경변수로 합성 적용. +- 상태 출력에 `Auth(Session)` 표기 추가. + +### 테스트 보강 +- `ChatWindowSlashPolicyTests`에 `login/logout` 파서 및 로그인 입력 파서 테스트 추가. + +### 검증 결과 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (415 passed, 0 failed). + +## 2026-04-04 추가 진행 기록 (연속 실행 4차: 계획 1~4) + +### 1) 권한/도구 UX 통합 +- `/permissions`, `/allowed-tools`, `/settings permissions`가 동일한 권한 상태 모델을 사용하도록 정렬. +- 공통 처리 추가: + - 권한 모드 적용 헬퍼(`ask|auto|deny`) + - 권한 상태 요약 텍스트 생성 + - 권한 팝업 오픈 경로 통일 +- `/allowed-tools`에서도 `ask|auto|deny|status`를 동일하게 지원. + +### 2) 루프 복구 테스트 확대 +- `AgentLoopCodeQualityTests`에 unknown/disallowed/no-progress 혼합 관점 테스트 추가. +- unknown/disallowed 중단 응답에 `tool_search` 가이드가 유지되는지 회귀 검증. + +### 3) 좌측 패널 단순화 2차 +- 표현 레벨 기준 정리: + - `rich`: 실패 필터 노출 + - `balanced/simple`: 실패 필터 비노출 +- Quick strip 표시 조건도 위 정책과 연동해 실패 버튼이 숨겨진 모드에서 과밀 표시를 방지. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (421 passed, 0 failed). diff --git a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs index de4a688..8bd0ebd 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs @@ -1662,6 +1662,52 @@ public class AgentLoopCodeQualityTests response.Should().Contain("반복 횟수: 3"); } + [Fact] + public void BuildUnknownToolLoopAbortResponse_ShouldGuideToolSearch() + { + var response = InvokePrivateStatic( + "BuildUnknownToolLoopAbortResponse", + "unknown_exec", + 4, + new List { "tool_search", "file_read", "process" }); + + response.Should().Contain("tool_search"); + response.Should().Contain("unknown_exec"); + } + + [Fact] + public void BuildDisallowedToolLoopAbortResponse_ShouldGuideToolSearch() + { + var response = InvokePrivateStatic( + "BuildDisallowedToolLoopAbortResponse", + "dangerous_exec", + 5, + new List { "tool_search", "file_read", "file_edit" }); + + response.Should().Contain("tool_search"); + response.Should().Contain("dangerous_exec"); + } + + [Fact] + public void MixedRecoverySignals_ShouldRemainConsistentAcrossUnknownDisallowedAndNoProgress() + { + var unknownPrompt = InvokePrivateStatic( + "BuildUnknownToolRecoveryPrompt", + "bad_tool", + new List { "tool_search", "file_read", "glob" }); + + var disallowedPrompt = InvokePrivateStatic( + "BuildDisallowedToolRecoveryPrompt", + "unsafe_tool", + new HashSet(StringComparer.OrdinalIgnoreCase) { "tool_search", "file_read" }, + new List { "tool_search", "file_read" }); + + unknownPrompt.Should().Contain("tool_search"); + disallowedPrompt.Should().Contain("tool_search"); + unknownPrompt.Should().Contain("bad_tool"); + disallowedPrompt.Should().Contain("unsafe_tool"); + } + [Fact] public void ResolveRequestedToolName_MapsCommonAliasWhenTargetIsActive() { diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs index c107ca9..edc2dc7 100644 --- a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs +++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs @@ -24,6 +24,8 @@ public class ChatWindowSlashPolicyTests [InlineData("add chrome :: stdio node server.js", "add", "chrome :: stdio node server.js")] [InlineData("remove all", "remove", "all")] [InlineData("reset", "reset", "")] + [InlineData("login chrome token-123", "login", "chrome token-123")] + [InlineData("logout all", "logout", "all")] [InlineData("status", "status", "")] public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget) { @@ -46,6 +48,18 @@ public class ChatWindowSlashPolicyTests argument.Should().Be(expectedArgument); } + [Theory] + [InlineData("/allowed-tools", "open", "")] + [InlineData("status", "status", "")] + [InlineData("ask", "ask", "")] + public void ParseGenericAction_ForAllowedTools_ShouldParseExpected(string displayText, string expectedAction, string expectedArgument) + { + var (action, argument) = ChatWindow.ParseGenericAction(displayText, "/allowed-tools"); + + action.Should().Be(expectedAction); + argument.Should().Be(expectedArgument); + } + [Theory] [InlineData(false, "stdio", true, false, null, "Disabled")] [InlineData(true, "stdio", false, false, null, "Enabled")] @@ -119,4 +133,23 @@ public class ChatWindowSlashPolicyTests tokens.Should().ContainInOrder("stdio", "C:\\\\Program Files\\\\node.exe", "server path.js", "--flag"); } + + [Fact] + public void ParseMcpLoginTarget_ShouldParseServerAndToken() + { + var (success, server, token, error) = ChatWindow.ParseMcpLoginTarget("chrome abc.def.123"); + + success.Should().BeTrue(error); + server.Should().Be("chrome"); + token.Should().Be("abc.def.123"); + } + + [Fact] + public void ParseMcpLoginTarget_ShouldFailWhenTokenMissing() + { + var (success, _, _, error) = ChatWindow.ParseMcpLoginTarget("chrome"); + + success.Should().BeFalse(); + error.Should().Contain("토큰"); + } } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index cc76c7d..de755b9 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -65,6 +65,7 @@ public partial class ChatWindow : Window private bool _userScrolled; // 사용자가 위로 스크롤했는지 private readonly HashSet _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase); // 경과 시간 표시 private readonly DispatcherTimer _elapsedTimer; @@ -1955,6 +1956,48 @@ public partial class ChatWindow : Window } } + private bool TryApplyPermissionModeFromAction(string action, out string appliedMode) + { + appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); + var next = action switch + { + "ask" => PermissionModeCatalog.Ask, + "auto" => PermissionModeCatalog.Auto, + "deny" => PermissionModeCatalog.Deny, + _ => null, + }; + + if (string.IsNullOrWhiteSpace(next)) + return false; + + _settings.Settings.Llm.FilePermission = next!; + _settings.Save(); + _appState.LoadFromSettings(_settings); + UpdatePermissionUI(); + SaveConversationSettings(); + RefreshInlineSettingsPanel(); + appliedMode = next!; + return true; + } + + private string BuildPermissionStatusText() + { + ChatConversation? currentConversation; + lock (_convLock) currentConversation = _currentConversation; + var summary = _appState.GetPermissionSummary(currentConversation); + var mode = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); + var overrides = summary.TopOverrides.Count > 0 + ? string.Join(", ", summary.TopOverrides.Select(x => $"{x.Key}:{x.Value}")) + : "없음"; + return $"현재 권한 모드: {mode}\n설명: {summary.Description}\n기본값: {summary.DefaultMode} · override: {summary.OverrideCount}개\n상위 override: {overrides}"; + } + + private void OpenPermissionPanelFromSlash(string command, string usageText) + { + BtnPermission_Click(this, new RoutedEventArgs()); + AppendLocalSlashResult(_activeTab, command, $"권한 설정 팝업을 열었습니다. ({usageText})"); + } + // ──── 데이터 활용 수준 메뉴 ──── private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e) @@ -5529,7 +5572,7 @@ public partial class ChatWindow : Window ("/doctor", "프로젝트/환경 점검 체크를 수행합니다.")); AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg, - ("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset 관리"), + ("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"), ("/agents", "에이전트 분담 전략 제시"), ("/plugin", "플러그인 구성 점검"), ("/reload-plugins", "플러그인 재로드 점검"), @@ -5838,7 +5881,7 @@ public partial class ChatWindow : Window continue; } - using var client = new McpClientService(server); + using var client = new McpClientService(BuildEffectiveMcpServer(server)); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); @@ -5890,7 +5933,7 @@ public partial class ChatWindow : Window if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) continue; - using var client = new McpClientService(server); + using var client = new McpClientService(BuildEffectiveMcpServer(server)); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); @@ -6186,11 +6229,33 @@ public partial class ChatWindow : Window "add" => ("add", target), "remove" => ("remove", target), "reset" => ("reset", target), + "login" => ("login", target), + "logout" => ("logout", target), "status" => ("status", target), _ => ("help", text), }; } + internal static (bool success, string serverTarget, string token, string error) ParseMcpLoginTarget(string target) + { + var raw = (target ?? "").Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return (false, "", "", "사용법: /mcp login <서버명> <토큰>"); + + var firstSpace = raw.IndexOf(' '); + if (firstSpace <= 0 || firstSpace >= raw.Length - 1) + return (false, "", "", "토큰이 누락되었습니다. 사용법: /mcp login <서버명> <토큰>"); + + var serverTarget = raw[..firstSpace].Trim(); + var token = raw[(firstSpace + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(serverTarget)) + return (false, "", "", "서버명이 비어 있습니다."); + if (string.IsNullOrWhiteSpace(token)) + return (false, "", "", "토큰이 비어 있습니다."); + + return (true, serverTarget, token, ""); + } + internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target) { var raw = (target ?? "").Trim(); @@ -6303,40 +6368,41 @@ public partial class ChatWindow : Window { var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name; var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant(); + var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : ""; if (!IsMcpServerEnabled(server)) { - lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}"); + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); continue; } if (!runtimeCheck) { - lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}"); + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); continue; } if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) { - lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}"); + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); continue; } - using var client = new McpClientService(server); + using var client = new McpClientService(BuildEffectiveMcpServer(server)); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); if (!connected) { - lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}"); + lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}"); continue; } var toolCount = client.Tools.Count; var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount); - lines.Add($"- {name} [{transport}] : {statusLabel}"); + lines.Add($"- {name} [{transport}] : {statusLabel}{authSuffix}"); } - lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset"); + lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset | login <서버명> <토큰> | logout <서버명|all>"); return string.Join("\n", lines); } @@ -6373,6 +6439,26 @@ public partial class ChatWindow : Window return partial?.Name ?? ""; } + private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server) + { + var clone = new McpServerEntry + { + Name = server.Name, + Command = server.Command, + Args = server.Args?.ToList() ?? new List(), + Env = new Dictionary(server.Env ?? new Dictionary(), StringComparer.OrdinalIgnoreCase), + Enabled = server.Enabled, + Transport = server.Transport, + Url = server.Url, + }; + + var key = clone.Name ?? ""; + if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token)) + clone.Env["MCP_AUTH_TOKEN"] = token; + + return clone; + } + private async Task HandleMcpSlashAsync(string displayText, CancellationToken ct = default) { var llm = _settings.Settings.Llm; @@ -6380,7 +6466,7 @@ public partial class ChatWindow : Window var servers = llm.McpServers; var (action, target) = ParseMcpAction(displayText); if (action == "help") - return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset"; + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>"; if (action == "status") return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); @@ -6389,10 +6475,45 @@ public partial class ChatWindow : Window { var changed = _sessionMcpEnabledOverrides.Count; _sessionMcpEnabledOverrides.Clear(); + _sessionMcpAuthTokens.Clear(); return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); } + if (action == "login") + { + var (ok, serverTarget, token, error) = ParseMcpLoginTarget(target); + if (!ok) + return error; + + var resolved = ResolveMcpServerName(servers, serverTarget); + if (string.IsNullOrWhiteSpace(resolved)) + return $"로그인 대상 서버를 찾지 못했습니다: {serverTarget}"; + + _sessionMcpAuthTokens[resolved] = token; + return $"MCP 세션 토큰을 설정했습니다: {resolved}\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); + } + + if (action == "logout") + { + if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase)) + { + var removed = _sessionMcpAuthTokens.Count; + _sessionMcpAuthTokens.Clear(); + return $"모든 MCP 세션 토큰을 제거했습니다. ({removed}개)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + + var resolved = ResolveMcpServerName(servers, target); + if (string.IsNullOrWhiteSpace(resolved)) + return $"로그아웃 대상 서버를 찾지 못했습니다: {target}"; + + var removedOne = _sessionMcpAuthTokens.Remove(resolved); + return $"MCP 세션 토큰 제거: {resolved} ({(removedOne ? 1 : 0)}개)\n" + + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); + } + if (action == "add") { var (ok, entry, error) = ParseMcpAddTarget(target); @@ -6421,6 +6542,7 @@ public partial class ChatWindow : Window var removed = servers.Count; servers.Clear(); _sessionMcpEnabledOverrides.Clear(); + _sessionMcpAuthTokens.Clear(); _settings.Save(); return $"MCP 서버를 모두 제거했습니다. ({removed}개)"; } @@ -6431,6 +6553,7 @@ public partial class ChatWindow : Window var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)); _sessionMcpEnabledOverrides.Remove(resolved); + _sessionMcpAuthTokens.Remove(resolved); _settings.Save(); return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" + await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); @@ -6485,7 +6608,7 @@ public partial class ChatWindow : Window return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false); } - return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset"; + return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse , /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>"; } private string BuildSlashHeapDumpText() @@ -6676,39 +6799,37 @@ public partial class ChatWindow : Window if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal)) { var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions"); - if (permAction is "ask" or "auto" or "deny") + if (TryApplyPermissionModeFromAction(permAction, out var appliedMode)) { - 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}로 변경했습니다."); + AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {appliedMode}로 변경했습니다.\n{BuildPermissionStatusText()}"); return; } if (permAction == "status") { - var mode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); - AppendLocalSlashResult(_activeTab, "/permissions", $"현재 권한 모드: {mode}\n사용법: /permissions ask|auto|deny|status"); + AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|auto|deny|status"); return; } - BtnPermission_Click(this, new RoutedEventArgs()); - AppendLocalSlashResult(_activeTab, "/permissions", "권한 설정 팝업을 열었습니다. (사용법: /permissions ask|auto|deny|status)"); + OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|auto|deny|status"); return; } if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal)) { - BtnPermission_Click(this, new RoutedEventArgs()); - AppendLocalSlashResult(_activeTab, "/allowed-tools", "허용 도구(권한) 설정 팝업을 열었습니다."); + var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools"); + if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode)) + { + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {allowedMode}로 변경했습니다.\n{BuildPermissionStatusText()}"); + return; + } + + if (toolAction == "status") + { + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|auto|deny|status"); + return; + } + + OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|auto|deny|status"); return; } if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal)) @@ -6729,8 +6850,7 @@ public partial class ChatWindow : Window if (settingsAction == "permissions") { - BtnPermission_Click(this, new RoutedEventArgs()); - AppendLocalSlashResult(_activeTab, "/settings", "권한 설정 팝업을 열었습니다."); + OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions"); return; } @@ -7979,7 +8099,15 @@ public partial class ChatWindow : Window || BtnQuickRunningFilter == null || BtnQuickFailedFilter == null || BtnQuickHotSort == null) return; - ConversationQuickStrip.Visibility = (_runningConversationCount > 0 || _failedConversationCount > 0 || _spotlightConversationCount > 0) + var showFailureFilter = GetAgentUiExpressionLevel() == "rich"; + if (BtnQuickFailedFilter != null) + BtnQuickFailedFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed; + + var hasQuickSignal = _runningConversationCount > 0 + || _spotlightConversationCount > 0 + || (showFailureFilter && _failedConversationCount > 0); + + ConversationQuickStrip.Visibility = hasQuickSignal ? Visibility.Visible : Visibility.Collapsed; @@ -7992,9 +8120,12 @@ public partial class ChatWindow : Window BtnQuickRunningFilter.BorderThickness = new Thickness(1); QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); - BtnQuickFailedFilter.Background = _failedOnlyFilter ? BrushFromHex("#FEF2F2") : BrushFromHex("#F8FAFC"); - BtnQuickFailedFilter.BorderBrush = _failedOnlyFilter ? BrushFromHex("#FCA5A5") : BrushFromHex("#E5E7EB"); - BtnQuickFailedFilter.BorderThickness = new Thickness(1); + if (BtnQuickFailedFilter != null) + { + BtnQuickFailedFilter.Background = _failedOnlyFilter ? BrushFromHex("#FEF2F2") : BrushFromHex("#F8FAFC"); + BtnQuickFailedFilter.BorderBrush = _failedOnlyFilter ? BrushFromHex("#FCA5A5") : BrushFromHex("#E5E7EB"); + BtnQuickFailedFilter.BorderThickness = new Thickness(1); + } QuickFailedLabel.Foreground = _failedOnlyFilter ? BrushFromHex("#991B1B") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC");