AX Agent 권한 UX 통합 및 복구/좌측패널 단순화 보강
Some checks failed
Release Gate / gate (push) Has been cancelled

- /permissions, /allowed-tools, /settings permissions를 동일 권한 상태 모델로 통합

- 권한 공통 처리 헬퍼 추가: 모드 적용(ask/auto/deny), 상태 요약 텍스트, 팝업 오픈 경로 통일

- /allowed-tools에서도 ask|auto|deny|status를 일관 동작으로 지원

- /mcp login/logout 및 reset 인증 초기화 반영 상태를 문맥에 맞게 유지

- 좌측/퀵 스트립 단순화 2차: 실패 필터는 rich 표현 레벨에서만 노출, balanced/simple 비노출

- 루프 복구 테스트 보강: unknown/disallowed/no-progress 혼합 관점 회귀 테스트 추가

- 문서 동기화: DEVELOPMENT.md, AGENT_ROADMAP.md에 2026-04-04 추가 진행 이력 반영

- 품질 게이트 확인: build 경고/오류 0, 전체 테스트 421 통과
This commit is contained in:
2026-04-04 01:36:05 +09:00
parent 52e9e34ade
commit cc1f1c4e6c
5 changed files with 292 additions and 38 deletions

View File

@@ -61,3 +61,5 @@
4. `additionalContext`는 가능한 경로에서 메시지 컨텍스트로 반영. 4. `additionalContext`는 가능한 경로에서 메시지 컨텍스트로 반영.
- 2026-04-04(추가): `/mcp add/remove/reset` 확장, `tool_search` 기반 복구 프롬프트 강화, 슬래시 힌트 밀도(`rich/balanced/simple`) 연동. - 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 전용으로 정렬.

View File

@@ -2748,3 +2748,45 @@ else:
### E. 검증 결과 ### E. 검증 결과
- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0). - `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0).
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (411 passed, 0 failed). - `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).

View File

@@ -1662,6 +1662,52 @@ public class AgentLoopCodeQualityTests
response.Should().Contain("반복 횟수: 3"); response.Should().Contain("반복 횟수: 3");
} }
[Fact]
public void BuildUnknownToolLoopAbortResponse_ShouldGuideToolSearch()
{
var response = InvokePrivateStatic<string>(
"BuildUnknownToolLoopAbortResponse",
"unknown_exec",
4,
new List<string> { "tool_search", "file_read", "process" });
response.Should().Contain("tool_search");
response.Should().Contain("unknown_exec");
}
[Fact]
public void BuildDisallowedToolLoopAbortResponse_ShouldGuideToolSearch()
{
var response = InvokePrivateStatic<string>(
"BuildDisallowedToolLoopAbortResponse",
"dangerous_exec",
5,
new List<string> { "tool_search", "file_read", "file_edit" });
response.Should().Contain("tool_search");
response.Should().Contain("dangerous_exec");
}
[Fact]
public void MixedRecoverySignals_ShouldRemainConsistentAcrossUnknownDisallowedAndNoProgress()
{
var unknownPrompt = InvokePrivateStatic<string>(
"BuildUnknownToolRecoveryPrompt",
"bad_tool",
new List<string> { "tool_search", "file_read", "glob" });
var disallowedPrompt = InvokePrivateStatic<string>(
"BuildDisallowedToolRecoveryPrompt",
"unsafe_tool",
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "tool_search", "file_read" },
new List<string> { "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] [Fact]
public void ResolveRequestedToolName_MapsCommonAliasWhenTargetIsActive() public void ResolveRequestedToolName_MapsCommonAliasWhenTargetIsActive()
{ {

View File

@@ -24,6 +24,8 @@ public class ChatWindowSlashPolicyTests
[InlineData("add chrome :: stdio node server.js", "add", "chrome :: stdio node server.js")] [InlineData("add chrome :: stdio node server.js", "add", "chrome :: stdio node server.js")]
[InlineData("remove all", "remove", "all")] [InlineData("remove all", "remove", "all")]
[InlineData("reset", "reset", "")] [InlineData("reset", "reset", "")]
[InlineData("login chrome token-123", "login", "chrome token-123")]
[InlineData("logout all", "logout", "all")]
[InlineData("status", "status", "")] [InlineData("status", "status", "")]
public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget) public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget)
{ {
@@ -46,6 +48,18 @@ public class ChatWindowSlashPolicyTests
argument.Should().Be(expectedArgument); 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] [Theory]
[InlineData(false, "stdio", true, false, null, "Disabled")] [InlineData(false, "stdio", true, false, null, "Disabled")]
[InlineData(true, "stdio", false, false, null, "Enabled")] [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"); 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("토큰");
}
} }

View File

@@ -65,6 +65,7 @@ public partial class ChatWindow : Window
private bool _userScrolled; // 사용자가 위로 스크롤했는지 private bool _userScrolled; // 사용자가 위로 스크롤했는지
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
// 경과 시간 표시 // 경과 시간 표시
private readonly DispatcherTimer _elapsedTimer; private 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) private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
@@ -5529,7 +5572,7 @@ public partial class ChatWindow : Window
("/doctor", "프로젝트/환경 점검 체크를 수행합니다.")); ("/doctor", "프로젝트/환경 점검 체크를 수행합니다."));
AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg, AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg,
("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset 관리"), ("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"),
("/agents", "에이전트 분담 전략 제시"), ("/agents", "에이전트 분담 전략 제시"),
("/plugin", "플러그인 구성 점검"), ("/plugin", "플러그인 구성 점검"),
("/reload-plugins", "플러그인 재로드 점검"), ("/reload-plugins", "플러그인 재로드 점검"),
@@ -5838,7 +5881,7 @@ public partial class ChatWindow : Window
continue; continue;
} }
using var client = new McpClientService(server); using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
@@ -5890,7 +5933,7 @@ public partial class ChatWindow : Window
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
continue; continue;
using var client = new McpClientService(server); using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
@@ -6186,11 +6229,33 @@ public partial class ChatWindow : Window
"add" => ("add", target), "add" => ("add", target),
"remove" => ("remove", target), "remove" => ("remove", target),
"reset" => ("reset", target), "reset" => ("reset", target),
"login" => ("login", target),
"logout" => ("logout", target),
"status" => ("status", target), "status" => ("status", target),
_ => ("help", text), _ => ("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) internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target)
{ {
var raw = (target ?? "").Trim(); var raw = (target ?? "").Trim();
@@ -6303,40 +6368,41 @@ public partial class ChatWindow : Window
{ {
var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name; var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant(); var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : "";
if (!IsMcpServerEnabled(server)) 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; continue;
} }
if (!runtimeCheck) 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; continue;
} }
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase)) 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; continue;
} }
using var client = new McpClientService(server); using var client = new McpClientService(BuildEffectiveMcpServer(server));
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8)); timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
if (!connected) 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; continue;
} }
var toolCount = client.Tools.Count; var toolCount = client.Tools.Count;
var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount); 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); return string.Join("\n", lines);
} }
@@ -6373,6 +6439,26 @@ public partial class ChatWindow : Window
return partial?.Name ?? ""; return partial?.Name ?? "";
} }
private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server)
{
var clone = new McpServerEntry
{
Name = server.Name,
Command = server.Command,
Args = server.Args?.ToList() ?? new List<string>(),
Env = new Dictionary<string, string>(server.Env ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
Enabled = server.Enabled,
Transport = server.Transport,
Url = server.Url,
};
var key = clone.Name ?? "";
if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token))
clone.Env["MCP_AUTH_TOKEN"] = token;
return clone;
}
private async Task<string> HandleMcpSlashAsync(string displayText, CancellationToken ct = default) private async Task<string> HandleMcpSlashAsync(string displayText, CancellationToken ct = default)
{ {
var llm = _settings.Settings.Llm; var llm = _settings.Settings.Llm;
@@ -6380,7 +6466,7 @@ public partial class ChatWindow : Window
var servers = llm.McpServers; var servers = llm.McpServers;
var (action, target) = ParseMcpAction(displayText); var (action, target) = ParseMcpAction(displayText);
if (action == "help") if (action == "help")
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset"; return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
if (action == "status") if (action == "status")
return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false); return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
@@ -6389,10 +6475,45 @@ public partial class ChatWindow : Window
{ {
var changed = _sessionMcpEnabledOverrides.Count; var changed = _sessionMcpEnabledOverrides.Count;
_sessionMcpEnabledOverrides.Clear(); _sessionMcpEnabledOverrides.Clear();
_sessionMcpAuthTokens.Clear();
return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" + return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); 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") if (action == "add")
{ {
var (ok, entry, error) = ParseMcpAddTarget(target); var (ok, entry, error) = ParseMcpAddTarget(target);
@@ -6421,6 +6542,7 @@ public partial class ChatWindow : Window
var removed = servers.Count; var removed = servers.Count;
servers.Clear(); servers.Clear();
_sessionMcpEnabledOverrides.Clear(); _sessionMcpEnabledOverrides.Clear();
_sessionMcpAuthTokens.Clear();
_settings.Save(); _settings.Save();
return $"MCP 서버를 모두 제거했습니다. ({removed}개)"; return $"MCP 서버를 모두 제거했습니다. ({removed}개)";
} }
@@ -6431,6 +6553,7 @@ public partial class ChatWindow : Window
var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase)); var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
_sessionMcpEnabledOverrides.Remove(resolved); _sessionMcpEnabledOverrides.Remove(resolved);
_sessionMcpAuthTokens.Remove(resolved);
_settings.Save(); _settings.Save();
return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" + return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" +
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false); 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 await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false);
} }
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset"; return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
} }
private string BuildSlashHeapDumpText() private string BuildSlashHeapDumpText()
@@ -6676,39 +6799,37 @@ public partial class ChatWindow : Window
if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal)) if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal))
{ {
var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions"); var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions");
if (permAction is "ask" or "auto" or "deny") if (TryApplyPermissionModeFromAction(permAction, out var appliedMode))
{ {
var next = permAction switch AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {appliedMode}로 변경했습니다.\n{BuildPermissionStatusText()}");
{
"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; return;
} }
if (permAction == "status") if (permAction == "status")
{ {
var mode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|auto|deny|status");
AppendLocalSlashResult(_activeTab, "/permissions", $"현재 권한 모드: {mode}\n사용법: /permissions ask|auto|deny|status");
return; return;
} }
BtnPermission_Click(this, new RoutedEventArgs()); OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|auto|deny|status");
AppendLocalSlashResult(_activeTab, "/permissions", "권한 설정 팝업을 열었습니다. (사용법: /permissions ask|auto|deny|status)");
return; return;
} }
if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal)) if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal))
{ {
BtnPermission_Click(this, new RoutedEventArgs()); var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools");
AppendLocalSlashResult(_activeTab, "/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; return;
} }
if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal)) if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal))
@@ -6729,8 +6850,7 @@ public partial class ChatWindow : Window
if (settingsAction == "permissions") if (settingsAction == "permissions")
{ {
BtnPermission_Click(this, new RoutedEventArgs()); OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions");
AppendLocalSlashResult(_activeTab, "/settings", "권한 설정 팝업을 열었습니다.");
return; return;
} }
@@ -7979,7 +8099,15 @@ public partial class ChatWindow : Window
|| BtnQuickRunningFilter == null || BtnQuickFailedFilter == null || BtnQuickHotSort == null) || BtnQuickRunningFilter == null || BtnQuickFailedFilter == null || BtnQuickHotSort == null)
return; 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.Visible
: Visibility.Collapsed; : Visibility.Collapsed;
@@ -7992,9 +8120,12 @@ public partial class ChatWindow : Window
BtnQuickRunningFilter.BorderThickness = new Thickness(1); BtnQuickRunningFilter.BorderThickness = new Thickness(1);
QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
BtnQuickFailedFilter.Background = _failedOnlyFilter ? BrushFromHex("#FEF2F2") : BrushFromHex("#F8FAFC"); if (BtnQuickFailedFilter != null)
BtnQuickFailedFilter.BorderBrush = _failedOnlyFilter ? BrushFromHex("#FCA5A5") : BrushFromHex("#E5E7EB"); {
BtnQuickFailedFilter.BorderThickness = new Thickness(1); 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); QuickFailedLabel.Foreground = _failedOnlyFilter ? BrushFromHex("#991B1B") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC"); BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC");