diff --git a/src/AxCopilot.Tests/Services/AppStateServiceTests.cs b/src/AxCopilot.Tests/Services/AppStateServiceTests.cs index c375d24..a34424b 100644 --- a/src/AxCopilot.Tests/Services/AppStateServiceTests.cs +++ b/src/AxCopilot.Tests/Services/AppStateServiceTests.cs @@ -13,7 +13,7 @@ public class AppStateServiceTests public void LoadFromSettings_ReflectsPermissionAndMcpSummary() { var settings = new SettingsService(); - settings.Settings.Llm.FilePermission = "Auto"; + settings.Settings.Llm.FilePermission = "AcceptEdits"; settings.Settings.Llm.AgentDecisionLevel = "normal"; settings.Settings.Llm.PlanMode = "always"; settings.Settings.Llm.ToolPermissions["process"] = "Deny"; @@ -29,7 +29,7 @@ public class AppStateServiceTests state.LoadFromSettings(settings); - state.Permissions.FilePermission.Should().Be("Auto"); + state.Permissions.FilePermission.Should().Be("AcceptEdits"); state.Permissions.AgentDecisionLevel.Should().Be("normal"); state.Permissions.PlanMode.Should().Be("always"); state.Permissions.ToolOverrideCount.Should().Be(1); @@ -346,14 +346,14 @@ public class AppStateServiceTests { var state = new AppStateService(); var settings = new SettingsService(); - settings.Settings.Llm.FilePermission = "Ask"; + settings.Settings.Llm.FilePermission = "Default"; settings.Settings.Llm.ToolPermissions["process"] = "Deny"; state.LoadFromSettings(settings); - var summary = state.GetPermissionSummary(new ChatConversation { Permission = "Auto" }); + var summary = state.GetPermissionSummary(new ChatConversation { Permission = "AcceptEdits" }); - summary.EffectiveMode.Should().Be("Auto"); - summary.DefaultMode.Should().Be("Ask"); + summary.EffectiveMode.Should().Be("AcceptEdits"); + summary.DefaultMode.Should().Be("Default"); summary.OverrideCount.Should().Be(1); summary.RiskLevel.Should().Be("high"); summary.TopOverrides.Should().ContainSingle(); diff --git a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs index 62ce38b..3ec2a9c 100644 --- a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs @@ -37,7 +37,7 @@ public class OperationModePolicyTests var context = new AgentContext { OperationMode = OperationModePolicy.InternalMode, - Permission = "Auto" + Permission = "AcceptEdits" }; var blocked = await context.CheckToolPermissionAsync("http_tool", "https://example.com"); @@ -53,7 +53,7 @@ public class OperationModePolicyTests var context = new AgentContext { OperationMode = OperationModePolicy.ExternalMode, - Permission = "Auto" + Permission = "AcceptEdits" }; var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com"); @@ -65,18 +65,18 @@ public class OperationModePolicyTests { var context = new AgentContext { - Permission = "Ask", + Permission = "Default", ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["process"] = "deny", - ["process@git *"] = "auto", - ["*@*.md"] = "ask", + ["process@git *"] = "acceptedits", + ["*@*.md"] = "default", } }; - context.GetEffectiveToolPermission("process", "git status").Should().Be("auto"); - context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("deny"); - context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("ask"); + context.GetEffectiveToolPermission("process", "git status").Should().Be("Default"); + context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("Deny"); + context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("AcceptEdits"); } [Fact] @@ -86,10 +86,10 @@ public class OperationModePolicyTests var context = new AgentContext { OperationMode = OperationModePolicy.ExternalMode, - Permission = "Ask", + Permission = "Default", ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["process@git *"] = "auto", + ["process@git *"] = "bypassPermissions", }, AskPermission = (_, _) => { @@ -104,29 +104,48 @@ public class OperationModePolicyTests } [Fact] - public void AgentContext_GetEffectiveToolPermission_DowngradesDangerousAutoToolWhenGlobalAuto() + public void AgentContext_GetEffectiveToolPermission_AcceptEditsAllowsWriteButKeepsProcessPrompted() { var context = new AgentContext { - Permission = "Auto", - ToolPermissions = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["process"] = "auto", - } + Permission = "AcceptEdits" }; - context.GetEffectiveToolPermission("process", "git status").Should().Be("ask"); - context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("Auto"); + context.GetEffectiveToolPermission("process", "git status").Should().Be("Default"); + context.GetEffectiveToolPermission("file_write", @"E:\work\out.txt").Should().Be("AcceptEdits"); } [Fact] - public async Task AgentContext_CheckToolPermissionAsync_DangerousAutoToolRequiresPromptInGlobalAuto() + public async Task AgentContext_CheckToolPermissionAsync_PlanModeBlocksWriteButAllowsRead() { var askCalled = false; var context = new AgentContext { OperationMode = OperationModePolicy.ExternalMode, - Permission = "Auto", + Permission = "Plan", + AskPermission = (_, _) => + { + askCalled = true; + return Task.FromResult(true); + } + }; + + var writeAllowed = await context.CheckToolPermissionAsync("file_write", @"E:\work\out.txt"); + var readAllowed = await context.CheckToolPermissionAsync("file_read", @"E:\work\in.txt"); + + writeAllowed.Should().BeFalse(); + readAllowed.Should().BeTrue(); + askCalled.Should().BeFalse(); + } + + [Fact] + public async Task AgentContext_CheckToolPermissionAsync_BypassPermissionsSkipsPrompt() + { + var askCalled = false; + var context = new AgentContext + { + OperationMode = OperationModePolicy.ExternalMode, + Permission = "BypassPermissions", AskPermission = (_, _) => { askCalled = true; @@ -135,8 +154,8 @@ public class OperationModePolicyTests }; var allowed = await context.CheckToolPermissionAsync("process", "git status"); - allowed.Should().BeFalse(); - askCalled.Should().BeTrue(); + allowed.Should().BeTrue(); + askCalled.Should().BeFalse(); } [Fact] diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index f17c408..1634b29 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -241,6 +241,10 @@ public class LauncherSettings /// 독 바 마지막 위치 Y. -1이면 하단. [JsonPropertyName("dockBarTop")] public double DockBarTop { get; set; } = -1; + + /// 모니터별 독 바 위치. key=디바이스명, value=[left, top] + [JsonPropertyName("monitorDockPositions")] + public Dictionary> MonitorDockPositions { get; set; } = new(StringComparer.OrdinalIgnoreCase); } /// @@ -591,14 +595,15 @@ public class LlmSettings /// /// 파일 접근 권한 수준. - /// Ask = 매번 확인 | Plan = 계획/승인 중심 | Auto = 자동 허용 | Deny = 차단 + /// Default = 매번 확인 | AcceptEdits = 파일 편집 자동 허용 | Plan = 계획/승인 중심 + /// BypassPermissions = 모든 확인 생략 | DontAsk = 권한 질문 없이 진행 | Deny = 읽기 전용 /// [JsonPropertyName("filePermission")] - public string FilePermission { get; set; } = "Ask"; + public string FilePermission { get; set; } = "Default"; /// Cowork/Code 탭의 기본 파일 접근 권한. 탭 전환 시 자동 적용. [JsonPropertyName("defaultAgentPermission")] - public string DefaultAgentPermission { get; set; } = "Ask"; + public string DefaultAgentPermission { get; set; } = "Default"; // ── 서비스별 독립 설정 ────────────────────────────────────── [JsonPropertyName("ollamaEndpoint")] public string OllamaEndpoint { get; set; } = "http://localhost:11434"; @@ -608,6 +613,7 @@ public class LlmSettings [JsonPropertyName("vllmEndpoint")] public string VllmEndpoint { get; set; } = ""; [JsonPropertyName("vllmApiKey")] public string VllmApiKey { get; set; } = ""; [JsonPropertyName("vllmModel")] public string VllmModel { get; set; } = ""; + [JsonPropertyName("vllmAllowInsecureTls")] public bool VllmAllowInsecureTls { get; set; } = false; [JsonPropertyName("geminiApiKey")] public string GeminiApiKey { get; set; } = ""; [JsonPropertyName("geminiModel")] public string GeminiModel { get; set; } = "gemini-2.5-flash"; @@ -636,6 +642,14 @@ public class LlmSettings [JsonPropertyName("maxAgentIterations")] public int MaxAgentIterations { get; set; } = 25; + /// 컨텍스트 사전 압축 활성화 여부. + [JsonPropertyName("enableProactiveContextCompact")] + public bool EnableProactiveContextCompact { get; set; } = true; + + /// 컨텍스트 압축 시작 임계치(%). 최대 토큰의 몇 %에서 압축 시작할지. + [JsonPropertyName("contextCompactTriggerPercent")] + public int ContextCompactTriggerPercent { get; set; } = 80; + /// 도구 실패 시 최대 재시도 횟수 (Self-Reflection). [JsonPropertyName("maxRetryOnError")] public int MaxRetryOnError { get; set; } = 3; @@ -648,6 +662,10 @@ public class LlmSettings [JsonPropertyName("agentLogLevel")] public string AgentLogLevel { get; set; } = "simple"; + /// AX Agent UI 표현 수준. rich | balanced | simple + [JsonPropertyName("agentUiExpressionLevel")] + public string AgentUiExpressionLevel { get; set; } = "balanced"; + /// 계획 diff 심각도 중간 기준: 변경 개수 임계값. [JsonPropertyName("planDiffSeverityMediumCount")] public int PlanDiffSeverityMediumCount { get; set; } = 2; @@ -699,9 +717,9 @@ public class LlmSettings [JsonPropertyName("lastActiveTab")] public string LastActiveTab { get; set; } = "Chat"; - /// 폴더 데이터 활용 모드. active(적극) | passive(소극) | none(미활용) + /// 폴더 데이터 활용 모드. none(미활용) | passive(소극) | active(적극) [JsonPropertyName("folderDataUsage")] - public string FolderDataUsage { get; set; } = "active"; + public string FolderDataUsage { get; set; } = "none"; /// /// 에이전트 의사결정 수준. AI가 중요한 작업 전에 사용자 확인을 요청하는 빈도. @@ -795,6 +813,10 @@ public class LlmSettings [JsonPropertyName("enableChatRainbowGlow")] public bool EnableChatRainbowGlow { get; set; } = false; + /// AX Agent 전용 테마. system | light | dark + [JsonPropertyName("agentTheme")] + public string AgentTheme { get; set; } = "system"; + // ─── 알림 ────────────────────────────────────────────────────────── /// 에이전트 작업 완료 시 시스템 알림 표시 여부. (Cowork/Code 공통) @@ -866,7 +888,7 @@ public class LlmSettings public List DisabledTools { get; set; } = new(); /// - /// 도구별 실행 권한 오버라이드. 키: 도구 이름, 값: "ask" | "auto" | "deny". + /// 도구별 실행 권한 오버라이드. 키: 도구 이름 또는 tool@pattern, 값: 권한 모드. /// 여기에 없는 도구는 전역 FilePermission 설정을 따릅니다. /// [JsonPropertyName("toolPermissions")] @@ -1125,6 +1147,10 @@ public class RegisteredModel [JsonPropertyName("apiKey")] public string ApiKey { get; set; } = ""; + /// 이 모델에 한해 TLS 인증서 검증을 생략합니다(vLLM 사내 인증서 예외 대응). + [JsonPropertyName("allowInsecureTls")] + public bool AllowInsecureTls { get; set; } = false; + // ── CP4D (IBM Cloud Pak for Data) 인증 ────────────────────────────── /// 인증 방식. bearer (기본) | cp4d diff --git a/src/AxCopilot/Services/Agent/IAgentTool.cs b/src/AxCopilot/Services/Agent/IAgentTool.cs index c0d157a..6a1457f 100644 --- a/src/AxCopilot/Services/Agent/IAgentTool.cs +++ b/src/AxCopilot/Services/Agent/IAgentTool.cs @@ -95,8 +95,22 @@ public class AgentContext private static readonly HashSet DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase) { "process", + "build_run", "spawn_agent", "snippet_runner", + "test_loop", + }; + private static readonly HashSet WriteTools = new(StringComparer.OrdinalIgnoreCase) + { + "file_write", "file_edit", "file_manage", + "html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create", + "chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint", + "todo_write", "skill_manager", "project_rule", "task_create", "task_update", "task_stop", + "team_create", "team_delete", "cron_create", "cron_delete", "zip", + }; + private static readonly HashSet ProcessLikeTools = new(StringComparer.OrdinalIgnoreCase) + { + "process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "git_tool", }; private readonly object _permissionLock = new(); @@ -104,10 +118,10 @@ public class AgentContext /// 작업 폴더 경로. public string WorkFolder { get; set; } = ""; - /// 파일 접근 권한. Ask | Plan | Auto | Deny - public string Permission { get; init; } = "Ask"; + /// 파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny + public string Permission { get; init; } = "Default"; - /// 도구별 권한 오버라이드. 키: 도구명, 값: "ask" | "auto" | "deny". + /// 도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드. public Dictionary ToolPermissions { get; init; } = new(); /// 차단 경로 패턴 목록. @@ -201,22 +215,22 @@ public class AgentContext var normalizedToolName = toolName.Trim(); if (TryResolvePatternPermission(toolName, target, out var patternPermission)) - return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission)); + return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission)); if (ToolPermissions.TryGetValue(toolName, out var toolPerm) && !string.IsNullOrWhiteSpace(toolPerm)) - return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm)); + return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm)); if (ToolPermissions.TryGetValue("*", out var wildcardPerm) && !string.IsNullOrWhiteSpace(wildcardPerm)) - return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(wildcardPerm)); + return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(wildcardPerm)); if (ToolPermissions.TryGetValue("default", out var defaultPerm) && !string.IsNullOrWhiteSpace(defaultPerm)) - return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm)); + return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm)); var fallback = SensitiveTools.Contains(toolName) ? PermissionModeCatalog.NormalizeGlobalMode(Permission) - : PermissionModeCatalog.Auto; - return ApplyDangerousAutoGuard(normalizedToolName, fallback); + : PermissionModeCatalog.AcceptEdits; + return ResolveModeForTool(normalizedToolName, fallback); } public async Task CheckToolPermissionAsync(string toolName, string target) @@ -256,6 +270,30 @@ public class AgentContext var normalizedTool = toolName.Trim(); var normalizedTarget = target.Trim(); + foreach (var kv in ToolPermissions) + { + if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern) + && string.Equals(ruleTool, normalizedTool, StringComparison.OrdinalIgnoreCase) + && WildcardMatch(normalizedTarget, rulePattern) + && PermissionModeCatalog.IsDeny(kv.Value)) + { + permission = kv.Value.Trim(); + return true; + } + } + + foreach (var kv in ToolPermissions) + { + if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern) + && string.Equals(ruleTool, "*", StringComparison.Ordinal) + && WildcardMatch(normalizedTarget, rulePattern) + && PermissionModeCatalog.IsDeny(kv.Value)) + { + permission = kv.Value.Trim(); + return true; + } + } + foreach (var kv in ToolPermissions) { if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern) @@ -308,15 +346,63 @@ public class AgentContext return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } + private string ResolveModeForTool(string toolName, string mode) + { + if (string.IsNullOrWhiteSpace(toolName)) + return mode; + + var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(mode); + if (PermissionModeCatalog.IsDeny(normalizedMode)) + return PermissionModeCatalog.Deny; + + if (PermissionModeCatalog.IsBypassPermissions(normalizedMode) || PermissionModeCatalog.IsDontAsk(normalizedMode)) + return normalizedMode; + + if (PermissionModeCatalog.IsPlan(normalizedMode)) + { + if (IsWriteTool(toolName)) + return PermissionModeCatalog.Deny; + + return IsProcessLikeTool(toolName) + ? PermissionModeCatalog.Default + : PermissionModeCatalog.AcceptEdits; + } + + if (PermissionModeCatalog.IsAcceptEdits(normalizedMode)) + { + if (IsWriteTool(toolName)) + return PermissionModeCatalog.AcceptEdits; + + return IsProcessLikeTool(toolName) + ? PermissionModeCatalog.Default + : ApplyDangerousAutoGuard(toolName, PermissionModeCatalog.AcceptEdits); + } + + if (PermissionModeCatalog.IsDefault(normalizedMode)) + { + if (!SensitiveTools.Contains(toolName)) + return PermissionModeCatalog.AcceptEdits; + + return ApplyDangerousAutoGuard(toolName, PermissionModeCatalog.Default); + } + + return ApplyDangerousAutoGuard(toolName, normalizedMode); + } + + private static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName); + + private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(toolName); + private string ApplyDangerousAutoGuard(string toolName, string permission) { if (string.IsNullOrWhiteSpace(toolName)) return permission; if (PermissionModeCatalog.IsAuto(permission) - && PermissionModeCatalog.IsAuto(Permission) + && !PermissionModeCatalog.IsBypassPermissions(permission) + && !PermissionModeCatalog.IsDontAsk(permission) && DangerousAutoTools.Contains(toolName)) - return "ask"; + return PermissionModeCatalog.Default; return permission; } diff --git a/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs b/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs index eb8e30f..70e21df 100644 --- a/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs +++ b/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs @@ -6,16 +6,22 @@ /// public static class PermissionModeCatalog { - public const string Ask = "Ask"; + public const string Default = "Default"; + public const string Ask = Default; + public const string AcceptEdits = "AcceptEdits"; public const string Plan = "Plan"; - public const string Auto = "Auto"; + public const string BypassPermissions = "BypassPermissions"; + public const string DontAsk = "DontAsk"; + public const string Auto = AcceptEdits; public const string Deny = "Deny"; public static readonly IReadOnlyList UserSelectableModes = new[] { - Ask, + Default, + AcceptEdits, Plan, - Auto, + BypassPermissions, + DontAsk, Deny, }; @@ -26,15 +32,23 @@ public static class PermissionModeCatalog public static string NormalizeGlobalMode(string? value) { if (string.IsNullOrWhiteSpace(value)) - return Ask; + return Default; return value.Trim().ToLowerInvariant() switch { + "default" => Default, "ask" => Ask, - "plan" => Plan, + "acceptedits" => AcceptEdits, + "accept" => AcceptEdits, "auto" => Auto, + "plan" => Plan, + "bypasspermissions" => BypassPermissions, + "bypass" => BypassPermissions, + "dontask" => DontAsk, + "don't ask" => DontAsk, + "allow" => AcceptEdits, "deny" => Deny, - _ => Ask, + _ => Default, }; } @@ -47,14 +61,32 @@ public static class PermissionModeCatalog var mode = NormalizeGlobalMode(value); return mode switch { - Auto => "auto", - Deny => "deny", - _ => "ask", + AcceptEdits => AcceptEdits, + Plan => Plan, + BypassPermissions => BypassPermissions, + DontAsk => DontAsk, + Deny => Deny, + _ => Default, }; } public static bool IsAuto(string? mode) => - string.Equals(NormalizeGlobalMode(mode), Auto, StringComparison.OrdinalIgnoreCase); + IsAcceptEdits(mode) || IsBypassPermissions(mode) || IsDontAsk(mode); + + public static bool IsDefault(string? mode) => + string.Equals(NormalizeGlobalMode(mode), Default, StringComparison.OrdinalIgnoreCase); + + public static bool IsAcceptEdits(string? mode) => + string.Equals(NormalizeGlobalMode(mode), AcceptEdits, StringComparison.OrdinalIgnoreCase); + + public static bool IsPlan(string? mode) => + string.Equals(NormalizeGlobalMode(mode), Plan, StringComparison.OrdinalIgnoreCase); + + public static bool IsBypassPermissions(string? mode) => + string.Equals(NormalizeGlobalMode(mode), BypassPermissions, StringComparison.OrdinalIgnoreCase); + + public static bool IsDontAsk(string? mode) => + string.Equals(NormalizeGlobalMode(mode), DontAsk, StringComparison.OrdinalIgnoreCase); public static bool IsDeny(string? mode) => string.Equals(NormalizeGlobalMode(mode), Deny, StringComparison.OrdinalIgnoreCase); @@ -62,7 +94,24 @@ public static class PermissionModeCatalog public static bool RequiresUserApproval(string? mode) { var normalized = NormalizeGlobalMode(mode); - return !string.Equals(normalized, Auto, StringComparison.OrdinalIgnoreCase) + return !IsAcceptEdits(normalized) + && !IsBypassPermissions(normalized) + && !IsDontAsk(normalized) && !string.Equals(normalized, Deny, StringComparison.OrdinalIgnoreCase); } + + public static string ToDisplayLabel(string? mode) + { + var normalized = NormalizeGlobalMode(mode); + return normalized switch + { + Default => "소극 활용", + AcceptEdits => "적극 활용", + Plan => "Plan", + BypassPermissions => "Bypass", + DontAsk => "DontAsk", + Deny => "활용하지 않음", + _ => normalized, + }; + } } diff --git a/src/AxCopilot/Services/AppStateService.cs b/src/AxCopilot/Services/AppStateService.cs index 0796de4..409d199 100644 --- a/src/AxCopilot/Services/AppStateService.cs +++ b/src/AxCopilot/Services/AppStateService.cs @@ -30,7 +30,7 @@ public sealed class AppStateService public sealed class PermissionPolicyState { - public string FilePermission { get; set; } = "Ask"; + public string FilePermission { get; set; } = "Default"; public string AgentDecisionLevel { get; set; } = "detailed"; public string PlanMode { get; set; } = "off"; public int ToolOverrideCount { get; set; } @@ -506,7 +506,9 @@ public sealed class AppStateService if (string.IsNullOrWhiteSpace(conversation?.Permission)) effective = defaultMode; - var risk = string.Equals(effective, PermissionModeCatalog.Auto, StringComparison.OrdinalIgnoreCase) + var risk = PermissionModeCatalog.IsBypassPermissions(effective) || PermissionModeCatalog.IsDontAsk(effective) + ? "critical" + : PermissionModeCatalog.IsAcceptEdits(effective) ? "high" : string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase) ? "locked" @@ -516,9 +518,11 @@ public sealed class AppStateService var description = effective switch { - "Auto" => "파일 작업을 자동 허용합니다.", - "Deny" => "파일 작업을 차단합니다.", + "AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.", + "Deny" => "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.", "Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.", + "BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.", + "DontAsk" => "권한 질문 없이 진행합니다. 자동 실행 범위를 반드시 점검해야 합니다.", _ => "파일 작업 전마다 사용자 확인을 요청합니다.", }; diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs new file mode 100644 index 0000000..c4f7132 --- /dev/null +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs @@ -0,0 +1,383 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; +using AxCopilot.Services.Agent; + +namespace AxCopilot.Views; + +public partial class AgentSettingsWindow : Window +{ + private const string UnifiedAdminPassword = "axgo123!"; + + private readonly SettingsService _settings; + private readonly LlmSettings _llm; + private string _permissionMode = PermissionModeCatalog.Ask; + private string _planMode = "off"; + private string _reasoningMode = "detailed"; + private string _folderDataUsage = "active"; + private string _operationMode = OperationModePolicy.InternalMode; + + public AgentSettingsWindow(SettingsService settings) + { + _settings = settings; + _llm = _settings.Settings.Llm; + InitializeComponent(); + LoadFromSettings(); + } + + private void LoadFromSettings() + { + ModelInput.Text = _llm.Model ?? ""; + _permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_llm.FilePermission); + _planMode = string.IsNullOrWhiteSpace(_llm.PlanMode) ? "off" : _llm.PlanMode; + _reasoningMode = string.IsNullOrWhiteSpace(_llm.AgentDecisionLevel) ? "detailed" : _llm.AgentDecisionLevel; + _folderDataUsage = string.IsNullOrWhiteSpace(_llm.FolderDataUsage) ? "active" : _llm.FolderDataUsage; + _operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode); + + ChkVllmAllowInsecureTls.IsChecked = _llm.VllmAllowInsecureTls; + ChkEnableProactiveCompact.IsChecked = _llm.EnableProactiveContextCompact; + TxtContextCompactTriggerPercent.Text = Math.Clamp(_llm.ContextCompactTriggerPercent, 10, 95).ToString(); + TxtMaxContextTokens.Text = Math.Max(1024, _llm.MaxContextTokens).ToString(); + TxtMaxRetryOnError.Text = Math.Clamp(_llm.MaxRetryOnError, 0, 10).ToString(); + + ChkEnableSkillSystem.IsChecked = _llm.EnableSkillSystem; + ChkEnableToolHooks.IsChecked = _llm.EnableToolHooks; + ChkEnableHookInputMutation.IsChecked = _llm.EnableHookInputMutation; + ChkEnableHookPermissionUpdate.IsChecked = _llm.EnableHookPermissionUpdate; + ChkEnableCoworkVerification.IsChecked = _llm.EnableCoworkVerification; + ChkEnableCodeVerification.IsChecked = _llm.Code.EnableCodeVerification; + ChkEnableParallelTools.IsChecked = _llm.EnableParallelTools; + + RefreshServiceCards(); + RefreshThemeCards(); + RefreshModeLabels(); + BuildModelChips(); + } + + private void RefreshThemeCards() + { + var selected = (_llm.AgentTheme ?? "system").ToLowerInvariant(); + SetCardSelection(ThemeSystemCard, selected == "system"); + SetCardSelection(ThemeLightCard, selected == "light"); + SetCardSelection(ThemeDarkCard, selected == "dark"); + } + + private void RefreshServiceCards() + { + var service = (_llm.Service ?? "ollama").ToLowerInvariant(); + SetCardSelection(SvcOllamaCard, service == "ollama"); + SetCardSelection(SvcVllmCard, service == "vllm"); + SetCardSelection(SvcGeminiCard, service == "gemini"); + SetCardSelection(SvcClaudeCard, service is "claude" or "sigmoid"); + } + + private void RefreshModeLabels() + { + BtnOperationMode.Content = BuildOperationModeLabel(_operationMode); + BtnPermissionMode.Content = PermissionModeCatalog.ToDisplayLabel(_permissionMode); + BtnPlanMode.Content = _planMode; + BtnReasoningMode.Content = _reasoningMode; + BtnFolderDataUsage.Content = _folderDataUsage; + AdvancedPanel.Visibility = Visibility.Visible; + } + + private static string BuildOperationModeLabel(string mode) + { + return OperationModePolicy.Normalize(mode) == OperationModePolicy.ExternalMode + ? "사외 모드" + : "사내 모드"; + } + + private void SetCardSelection(Border border, bool selected) + { + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue; + var normal = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + border.BorderBrush = selected ? accent : normal; + border.Background = selected + ? (TryFindResource("HintBackground") as Brush ?? Brushes.Transparent) + : Brushes.Transparent; + } + + private void BuildModelChips() + { + ModelChipPanel.Children.Clear(); + var models = GetModelCandidates(_llm.Service); + foreach (var model in models) + { + var captured = model; + var border = new Border + { + Cursor = Cursors.Hand, + CornerRadius = new CornerRadius(10), + BorderThickness = new Thickness(1), + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + Background = Brushes.Transparent, + Padding = new Thickness(10, 6, 10, 6), + Margin = new Thickness(0, 0, 8, 8), + Child = new TextBlock + { + Text = model, + FontSize = 11, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + }, + }; + border.MouseLeftButtonUp += (_, _) => ModelInput.Text = captured; + ModelChipPanel.Children.Add(border); + } + } + + private List GetModelCandidates(string? service) + { + var key = (service ?? "ollama").ToLowerInvariant(); + var result = new List(); + + foreach (var m in _llm.RegisteredModels) + { + if (!string.Equals(m.Service, key, StringComparison.OrdinalIgnoreCase)) + continue; + if (!string.IsNullOrWhiteSpace(m.Alias) && !result.Contains(m.Alias)) + result.Add(m.Alias); + } + + void AddIf(string? value) + { + if (!string.IsNullOrWhiteSpace(value) && !result.Contains(value)) + result.Add(value); + } + + if (key == "ollama") AddIf(_llm.OllamaModel); + else if (key == "vllm") AddIf(_llm.VllmModel); + else if (key == "gemini") AddIf(_llm.GeminiModel); + else AddIf(_llm.ClaudeModel); + + return result; + } + + private void SetService(string service) + { + _llm.Service = service; + RefreshServiceCards(); + BuildModelChips(); + } + + private void SetTheme(string theme) + { + _llm.AgentTheme = theme; + RefreshThemeCards(); + } + + private static string CycleOperationMode(string current) + { + return OperationModePolicy.Normalize(current) == OperationModePolicy.ExternalMode + ? OperationModePolicy.InternalMode + : OperationModePolicy.ExternalMode; + } + + private bool PromptPasswordDialog(string title, string header, string message) + { + var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White; + var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black; + var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke; + + var dlg = new Window + { + Title = title, + Width = 340, + SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Owner = this, + ResizeMode = ResizeMode.NoResize, + WindowStyle = WindowStyle.None, + AllowsTransparency = true, + Background = Brushes.Transparent, + ShowInTaskbar = false, + }; + + var border = new Border + { + Background = bgBrush, + CornerRadius = new CornerRadius(12), + BorderBrush = borderBrush, + BorderThickness = new Thickness(1), + Padding = new Thickness(20), + }; + + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = header, + FontSize = 15, + FontWeight = FontWeights.SemiBold, + Foreground = fgBrush, + Margin = new Thickness(0, 0, 0, 12), + }); + stack.Children.Add(new TextBlock + { + Text = message, + FontSize = 12, + Foreground = subFgBrush, + Margin = new Thickness(0, 0, 0, 6), + }); + + var pwBox = new PasswordBox + { + FontSize = 14, + Padding = new Thickness(8, 6, 8, 6), + Background = itemBg, + Foreground = fgBrush, + BorderBrush = borderBrush, + PasswordChar = '*', + }; + stack.Children.Add(pwBox); + + var btnRow = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Margin = new Thickness(0, 16, 0, 0), + }; + + var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) }; + cancelBtn.Click += (_, _) => dlg.DialogResult = false; + btnRow.Children.Add(cancelBtn); + + var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true }; + okBtn.Click += (_, _) => + { + if (pwBox.Password == UnifiedAdminPassword) + dlg.DialogResult = true; + else + { + pwBox.Clear(); + pwBox.Focus(); + } + }; + btnRow.Children.Add(okBtn); + stack.Children.Add(btnRow); + + border.Child = stack; + dlg.Content = border; + dlg.Loaded += (_, _) => pwBox.Focus(); + return dlg.ShowDialog() == true; + } + + private void ThemeSystemCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetTheme("system"); + private void ThemeLightCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetTheme("light"); + private void ThemeDarkCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetTheme("dark"); + + private void SvcOllamaCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("ollama"); + private void SvcVllmCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("vllm"); + private void SvcGeminiCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("gemini"); + private void SvcClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("claude"); + + private void BtnOperationMode_Click(object sender, RoutedEventArgs e) + { + var next = CycleOperationMode(_operationMode); + if (!PromptPasswordDialog( + "운영 모드 변경", + "사내/사외 모드 변경", + "비밀번호를 입력하세요:")) + { + return; + } + + _operationMode = next; + RefreshModeLabels(); + } + + private void BtnPermissionMode_Click(object sender, RoutedEventArgs e) + { + _permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_permissionMode) switch + { + PermissionModeCatalog.Deny => PermissionModeCatalog.Default, + PermissionModeCatalog.Default => PermissionModeCatalog.AcceptEdits, + PermissionModeCatalog.AcceptEdits => PermissionModeCatalog.Plan, + PermissionModeCatalog.Plan => PermissionModeCatalog.BypassPermissions, + PermissionModeCatalog.BypassPermissions => PermissionModeCatalog.DontAsk, + _ => PermissionModeCatalog.Deny, + }; + RefreshModeLabels(); + } + + private void BtnPlanMode_Click(object sender, RoutedEventArgs e) + { + _planMode = _planMode switch + { + "off" => "auto", + "auto" => "always", + _ => "off", + }; + RefreshModeLabels(); + } + + private void BtnReasoningMode_Click(object sender, RoutedEventArgs e) + { + _reasoningMode = _reasoningMode switch + { + "minimal" => "normal", + "normal" => "detailed", + _ => "minimal", + }; + RefreshModeLabels(); + } + + private void BtnFolderDataUsage_Click(object sender, RoutedEventArgs e) + { + _folderDataUsage = _folderDataUsage switch + { + "none" => "passive", + "passive" => "active", + _ => "none", + }; + RefreshModeLabels(); + } + + private void BtnSave_Click(object sender, RoutedEventArgs e) + { + _llm.Model = ModelInput.Text.Trim(); + _llm.FilePermission = _permissionMode; + _llm.PlanMode = _planMode; + _llm.AgentDecisionLevel = _reasoningMode; + _llm.FolderDataUsage = _folderDataUsage; + _llm.AgentUiExpressionLevel = "rich"; + + _llm.VllmAllowInsecureTls = ChkVllmAllowInsecureTls.IsChecked == true; + _llm.EnableProactiveContextCompact = ChkEnableProactiveCompact.IsChecked == true; + _llm.ContextCompactTriggerPercent = ParseInt(TxtContextCompactTriggerPercent.Text, 80, 10, 95); + _llm.MaxContextTokens = ParseInt(TxtMaxContextTokens.Text, 4096, 1024, 200000); + _llm.MaxRetryOnError = ParseInt(TxtMaxRetryOnError.Text, 3, 0, 10); + + _llm.EnableSkillSystem = ChkEnableSkillSystem.IsChecked == true; + _llm.EnableToolHooks = ChkEnableToolHooks.IsChecked == true; + _llm.EnableHookInputMutation = ChkEnableHookInputMutation.IsChecked == true; + _llm.EnableHookPermissionUpdate = ChkEnableHookPermissionUpdate.IsChecked == true; + _llm.EnableCoworkVerification = ChkEnableCoworkVerification.IsChecked == true; + _llm.Code.EnableCodeVerification = ChkEnableCodeVerification.IsChecked == true; + _llm.EnableParallelTools = ChkEnableParallelTools.IsChecked == true; + + _settings.Settings.OperationMode = OperationModePolicy.Normalize(_operationMode); + _settings.Save(); + DialogResult = true; + Close(); + } + + private void BtnOpenFullSettings_Click(object sender, RoutedEventArgs e) + { + if (System.Windows.Application.Current is App app) + app.OpenSettingsFromChat(); + } + + private void BtnClose_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => Close(); + + private static int ParseInt(string? text, int fallback, int min, int max) + { + if (!int.TryParse(text, out var value)) + value = fallback; + return Math.Clamp(value, min, max); + } +} diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index de755b9..9492e96 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -76,6 +76,7 @@ public partial class ChatWindow : Window private readonly DispatcherTimer _typingTimer; private int _displayedLength; // 현재 화면에 표시된 글자 수 private ResourceDictionary? _agentThemeDictionary; + private AgentSettingsWindow? _agentSettingsWindow; private sealed class ConversationMeta { @@ -150,7 +151,7 @@ public partial class ChatWindow : Window // 설정에서 초기값 로드 (Loaded 전에도 null 방지) _selectedMood = settings.Settings.Llm.DefaultMood ?? "modern"; - _folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "active"; + _folderDataUsage = settings.Settings.Llm.FolderDataUsage ?? "none"; _cursorTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) }; _cursorTimer.Tick += CursorTimer_Tick; @@ -172,7 +173,7 @@ public partial class ChatWindow : Window // ── 즉시 필요한 UI 초기화만 동기 실행 ── SetupUserInfo(); _selectedMood = _settings.Settings.Llm.DefaultMood ?? "modern"; - _folderDataUsage = _settings.Settings.Llm.FolderDataUsage ?? "active"; + _folderDataUsage = _settings.Settings.Llm.FolderDataUsage ?? "none"; UpdateAnalyzerButtonVisibility(); UpdateModelLabel(); RefreshInlineSettingsPanel(); @@ -232,19 +233,6 @@ public partial class ChatWindow : Window UpdateConditionalSkillActivation(reset: true); } - // 슬래시 팝업 네비게이션 버튼 - SlashNavUp.MouseLeftButtonUp += (_, _) => - { - _slashPageOffset = Math.Max(0, _slashPageOffset - SlashPageSize); - RenderSlashPage(); - }; - SlashNavDown.MouseLeftButtonUp += (_, _) => - { - _slashPageOffset = Math.Min(_slashAllMatches.Count - 1, - _slashPageOffset + SlashPageSize); - RenderSlashPage(); - }; - // 슬래시 명령어 칩 닫기 (× 버튼) SlashChipClose.MouseLeftButtonUp += (_, _) => { @@ -752,8 +740,6 @@ public partial class ChatWindow : Window } } - if (SidebarChatFailedState != null) - SidebarChatFailedState.Text = _failedOnlyFilter ? "ON" : "OFF"; if (SidebarChatRunningState != null) SidebarChatRunningState.Text = _runningOnlyFilter ? "ON" : "OFF"; } @@ -765,9 +751,6 @@ public partial class ChatWindow : Window 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()); @@ -1015,21 +998,13 @@ public partial class ChatWindow : Window if (_activeTab == "Cowork") { BuildBottomBar(); - if (_settings.Settings.Llm.ShowFileBrowser && FileBrowserPanel != null) - { - FileBrowserPanel.Visibility = Visibility.Visible; - BuildFileTree(); - } + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; } else if (_activeTab == "Code") { - // Code 탭: 언어 선택기 + 파일 탐색기 + // Code 탭: 언어 선택기 BuildCodeBottomBar(); - if (_settings.Settings.Llm.ShowFileBrowser && FileBrowserPanel != null) - { - FileBrowserPanel.Visibility = Visibility.Visible; - BuildFileTree(); - } + if (FileBrowserPanel != null) FileBrowserPanel.Visibility = Visibility.Collapsed; } else { @@ -1114,21 +1089,11 @@ public partial class ChatWindow : Window : new Thickness(10, 6, 10, 6); } - // simple/balanced 모드에서는 실패 전용 필터 UI를 숨겨 정보 과밀도를 줄입니다. - var showFailureFilter = level == "rich"; - if (!showFailureFilter && _failedOnlyFilter) + if (_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() @@ -1596,7 +1561,7 @@ public partial class ChatWindow : Window if (conv != null && conv.Permission != null) _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(conv.Permission); - _folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "active"; + _folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "none"; _selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern"; } @@ -1658,7 +1623,7 @@ public partial class ChatWindow : Window { new TextBlock { - Text = $"현재 권한 모드 · {summary.EffectiveMode}", + Text = $"현재 권한 모드 · {PermissionModeCatalog.ToDisplayLabel(summary.EffectiveMode)}", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = string.Equals(summary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) @@ -1776,9 +1741,9 @@ public partial class ChatWindow : Window return button; } - actionRow.Children.Add(CreateActionButton("Ask로 설정", "#F8FAFC", "#334155", () => SetToolPermissionOverride(latestDenied.ToolName!, "Ask"))); - actionRow.Children.Add(CreateActionButton("Auto로 설정", "#FFF7ED", "#C2410C", () => SetToolPermissionOverride(latestDenied.ToolName!, "Auto"))); - actionRow.Children.Add(CreateActionButton("Deny 유지", "#FEF2F2", "#991B1B", () => SetToolPermissionOverride(latestDenied.ToolName!, "Deny"))); + actionRow.Children.Add(CreateActionButton("기본으로", "#F8FAFC", "#334155", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Default))); + actionRow.Children.Add(CreateActionButton("적극 허용", "#FFF7ED", "#C2410C", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.AcceptEdits))); + actionRow.Children.Add(CreateActionButton("차단 유지", "#FEF2F2", "#991B1B", () => SetToolPermissionOverride(latestDenied.ToolName!, PermissionModeCatalog.Deny))); actionRow.Children.Add(CreateActionButton("override 해제", "#F3F4F6", "#374151", () => SetToolPermissionOverride(latestDenied.ToolName!, null))); deniedStack.Children.Add(actionRow); } @@ -1795,14 +1760,16 @@ public partial class ChatWindow : Window }); } - var levels = new (string Level, string Sym, string Desc, string Color)[] { - ("Ask", "\uE8D7", "매번 확인 — 파일 접근 시 사용자에게 묻습니다", "#4B5EFC"), - ("Plan", "\uE7C3", "계획/승인 중심 — 실행 전 계획과 사용자 확인 흐름을 우선합니다", "#4338CA"), - ("Auto", "\uE73E", "자동 허용 — 파일을 자동으로 읽고 씁니다", "#DD6B20"), - ("Deny", "\uE711", "접근 차단 — 파일 접근을 허용하지 않습니다", "#C50F1F"), + var levels = new (string Level, string Sym, string Title, string Desc, string Color)[] { + (PermissionModeCatalog.Deny, "\uE711", "활용하지 않음", "읽기 전용 — 파일 읽기만 허용하고 생성/수정/삭제는 차단합니다", "#107C10"), + (PermissionModeCatalog.Default, "\uE8D7", "소극 활용", "매번 확인 — 파일 접근이나 명령 실행 전에 사용자에게 묻습니다", "#4B5EFC"), + (PermissionModeCatalog.AcceptEdits, "\uE73E", "적극 활용", "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다", "#DD6B20"), + (PermissionModeCatalog.Plan, "\uE7C3", "Plan", "계획/승인 중심 — 실행 전 계획과 사용자 확인 흐름을 우선합니다", "#4338CA"), + (PermissionModeCatalog.BypassPermissions, "\uE7BA", "Bypass", "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다", "#DC2626"), + (PermissionModeCatalog.DontAsk, "\uE8A5", "DontAsk", "권한 질문 없이 계속 진행합니다. 자동 실행 범위를 점검해야 합니다", "#B91C1C"), }; var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); - foreach (var (level, sym, desc, color) in levels) + foreach (var (level, sym, title, desc, color) in levels) { var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); @@ -1845,7 +1812,7 @@ public partial class ChatWindow : Window var textStack = new StackPanel(); textStack.Children.Add(new TextBlock { - Text = level, FontSize = 13, FontWeight = FontWeights.Bold, + Text = title, FontSize = 13, FontWeight = FontWeights.Bold, Foreground = BrushFromHex(color), }); textStack.Children.Add(new TextBlock @@ -1862,8 +1829,11 @@ public partial class ChatWindow : Window btn.Click += (_, _) => { _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); + try { _settings.Save(); } catch { } + _appState.LoadFromSettings(_settings); UpdatePermissionUI(); SaveConversationSettings(); + RefreshInlineSettingsPanel(); PermissionPopup.IsOpen = false; }; PermissionItems.Children.Add(btn); @@ -1899,13 +1869,14 @@ public partial class ChatWindow : Window BtnPermission_Click(this, new RoutedEventArgs()); } - private bool _autoWarningDismissed; // Auto 경고 배너 사용자가 닫았는지 + private bool _permissionTopBannerDismissed; // 상단 권한 배너 닫힘 상태 + private string _lastPermissionBannerMode = ""; - private void BtnAutoWarningClose_Click(object sender, RoutedEventArgs e) + private void BtnPermissionTopBannerClose_Click(object sender, RoutedEventArgs e) { - _autoWarningDismissed = true; - if (AutoPermissionWarning != null) - AutoPermissionWarning.Visibility = Visibility.Collapsed; + _permissionTopBannerDismissed = true; + if (PermissionTopBanner != null) + PermissionTopBanner.Visibility = Visibility.Collapsed; } private void UpdatePermissionUI() @@ -1915,44 +1886,88 @@ public partial class ChatWindow : Window lock (_convLock) currentConversation = _currentConversation; var summary = _appState.GetPermissionSummary(currentConversation); var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); - PermissionLabel.Text = perm; + PermissionLabel.Text = PermissionModeCatalog.ToDisplayLabel(perm); PermissionIcon.Text = perm switch { + "AcceptEdits" => "\uE73E", "Plan" => "\uE7C3", - "Auto" => "\uE73E", + "BypassPermissions" => "\uE7BA", + "DontAsk" => "\uE8A5", "Deny" => "\uE711", _ => "\uE8D7", }; if (BtnPermission != null) BtnPermission.ToolTip = $"{summary.Description}\n기본값 {summary.DefaultMode} · override {summary.OverrideCount}개"; - // Auto 모드일 때 경고 색상 + 배너 표시 - if (perm == "Auto") + if (!string.Equals(_lastPermissionBannerMode, perm, StringComparison.OrdinalIgnoreCase)) + { + _permissionTopBannerDismissed = false; + _lastPermissionBannerMode = perm; + } + + // 모드별 색상 + 상단 권한 배너 표시 + if (perm == PermissionModeCatalog.AcceptEdits) { var warnColor = new SolidColorBrush(Color.FromRgb(0xDD, 0x6B, 0x20)); PermissionLabel.Foreground = warnColor; PermissionIcon.Foreground = warnColor; - // Auto 전환 시 새 대화에서만 1회 필수 표시 (기존 대화에서 이미 Auto였으면 숨김) - ChatConversation? convForWarn; - lock (_convLock) convForWarn = _currentConversation; - var isExisting = convForWarn != null && convForWarn.Messages.Count > 0 && convForWarn.Permission == "Auto"; - if (AutoPermissionWarning != null && !_autoWarningDismissed && !isExisting) - AutoPermissionWarning.Visibility = Visibility.Visible; + if (PermissionTopBanner != null) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#FDBA74"); + PermissionTopBannerIcon.Text = "\uE7BA"; + PermissionTopBannerIcon.Foreground = warnColor; + PermissionTopBannerTitle.Text = "현재 권한 모드 · 적극 활용"; + PermissionTopBannerTitle.Foreground = BrushFromHex("#C2410C"); + PermissionTopBannerText.Text = "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다."; + PermissionTopBanner.Visibility = _permissionTopBannerDismissed ? Visibility.Collapsed : Visibility.Visible; + } + } + else if (perm == "Deny") + { + var denyColor = new SolidColorBrush(Color.FromRgb(0x10, 0x7C, 0x10)); + PermissionLabel.Foreground = denyColor; + PermissionIcon.Foreground = denyColor; + if (PermissionTopBanner != null) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#86EFAC"); + PermissionTopBannerIcon.Text = "\uE73E"; + PermissionTopBannerIcon.Foreground = denyColor; + PermissionTopBannerTitle.Text = "현재 권한 모드 · 활용하지 않음"; + PermissionTopBannerTitle.Foreground = denyColor; + PermissionTopBannerText.Text = "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다."; + PermissionTopBanner.Visibility = _permissionTopBannerDismissed ? Visibility.Collapsed : Visibility.Visible; + } + } + else if (perm == PermissionModeCatalog.BypassPermissions || perm == PermissionModeCatalog.DontAsk) + { + var dangerColor = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)); + PermissionLabel.Foreground = dangerColor; + PermissionIcon.Foreground = dangerColor; + if (PermissionTopBanner != null) + { + PermissionTopBanner.BorderBrush = BrushFromHex("#FCA5A5"); + PermissionTopBannerIcon.Text = "\uE814"; + PermissionTopBannerIcon.Foreground = dangerColor; + PermissionTopBannerTitle.Text = perm == PermissionModeCatalog.BypassPermissions + ? "현재 권한 모드 · Bypass" + : "현재 권한 모드 · DontAsk"; + PermissionTopBannerTitle.Foreground = dangerColor; + PermissionTopBannerText.Text = "권한 확인을 거의 생략합니다. 민감한 작업 전에는 설정을 다시 확인하세요."; + PermissionTopBanner.Visibility = _permissionTopBannerDismissed ? Visibility.Collapsed : Visibility.Visible; + } } else { - _autoWarningDismissed = false; // Auto가 아닌 모드로 전환하면 리셋 var defaultFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; var iconFg = perm switch { - "Deny" => new SolidColorBrush(Color.FromRgb(0xC5, 0x0F, 0x1F)), "Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)), - _ => new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)), // Ask = 파란색 + _ => new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)), }; PermissionLabel.Foreground = defaultFg; PermissionIcon.Foreground = iconFg; - if (AutoPermissionWarning != null) - AutoPermissionWarning.Visibility = Visibility.Collapsed; + if (PermissionTopBanner != null) + PermissionTopBanner.Visibility = Visibility.Collapsed; } } @@ -1961,9 +1976,16 @@ public partial class ChatWindow : Window appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); var next = action switch { - "ask" => PermissionModeCatalog.Ask, - "auto" => PermissionModeCatalog.Auto, + "ask" => PermissionModeCatalog.Default, + "default" => PermissionModeCatalog.Default, + "acceptedits" => PermissionModeCatalog.AcceptEdits, + "accept" => PermissionModeCatalog.AcceptEdits, + "auto" => PermissionModeCatalog.AcceptEdits, "deny" => PermissionModeCatalog.Deny, + "plan" => PermissionModeCatalog.Plan, + "bypass" => PermissionModeCatalog.BypassPermissions, + "bypasspermissions" => PermissionModeCatalog.BypassPermissions, + "dontask" => PermissionModeCatalog.DontAsk, _ => null, }; @@ -1989,7 +2011,7 @@ public partial class ChatWindow : Window 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}"; + return $"현재 권한 모드: {PermissionModeCatalog.ToDisplayLabel(mode)} ({mode})\n설명: {summary.Description}\n기본값: {summary.DefaultMode} · override: {summary.OverrideCount}개\n상위 override: {overrides}"; } private void OpenPermissionPanelFromSlash(string command, string usageText) @@ -2005,14 +2027,14 @@ public partial class ChatWindow : Window if (DataUsagePopup == null) return; DataUsageItems.Children.Clear(); - var options = new (string Key, string Sym, string Label, string Desc, string Color)[] + var options = new (string Key, string Sym, string Label, string Desc, string Color, string CheckColor)[] { - ("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10"), - ("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706"), - ("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽거나 참조하지 않습니다", "#9CA3AF"), + ("none", "\uE8D8", "활용하지 않음", "폴더 내 문서를 읽기만 포함해 참조하지 않습니다", "#6B7280", "#6B7280"), + ("passive", "\uE8FD", "소극 활용", "사용자가 요청할 때만 폴더 데이터를 참조합니다", "#D97706", "#D97706"), + ("active", "\uE9F5", "적극 활용", "폴더 내 문서를 자동 탐색하여 보고서 작성에 적극 활용합니다", "#107C10", "#107C10"), }; - foreach (var (key, sym, label, desc, color) in options) + foreach (var (key, sym, label, desc, color, checkColor) in options) { var isActive = key.Equals(_folderDataUsage, StringComparison.OrdinalIgnoreCase); @@ -2042,8 +2064,10 @@ public partial class ChatWindow : Window }; ApplyHoverScaleAnimation(btn, 1.02); var sp = new StackPanel { Orientation = Orientation.Horizontal }; - // 커스텀 체크 아이콘 - sp.Children.Add(CreateCheckIcon(isActive)); + var checkIcon = CreateCheckIcon(isActive); + if (checkIcon is TextBlock checkText) + checkText.Foreground = isActive ? BrushFromHex(checkColor) : Brushes.Transparent; + sp.Children.Add(checkIcon); sp.Children.Add(new TextBlock { Text = sym, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, @@ -2084,8 +2108,8 @@ public partial class ChatWindow : Window if (DataUsageLabel == null || DataUsageIcon == null) return; var (label, icon, color) = _folderDataUsage switch { + "none" => ("미활용", "\uE8D8", "#6B7280"), "passive" => ("소극", "\uE8FD", "#D97706"), - "none" => ("미사용", "\uE8D8", "#9CA3AF"), _ => ("적극", "\uE9F5", "#107C10"), }; DataUsageLabel.Text = label; @@ -2463,8 +2487,6 @@ public partial class ChatWindow : Window i.LastAgentRunSummary.Contains(search, StringComparison.OrdinalIgnoreCase) ).ToList(); - if (_failedOnlyFilter) - items = items.Where(i => i.FailedAgentRunCount > 0).ToList(); if (_runningOnlyFilter) items = items.Where(i => i.IsRunning).ToList(); @@ -3344,13 +3366,7 @@ public partial class ChatWindow : Window RefreshConversationList(); } - private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) - { - _failedOnlyFilter = false; - UpdateConversationFailureFilterUi(); - PersistConversationListPreferences(); - RefreshConversationList(); - } + private void BtnFailedOnlyFilter_Click(object sender, RoutedEventArgs e) { } private void BtnRunningOnlyFilter_Click(object sender, RoutedEventArgs e) { @@ -3363,9 +3379,6 @@ public partial class ChatWindow : Window private void BtnQuickRunningFilter_Click(object sender, RoutedEventArgs e) => BtnRunningOnlyFilter_Click(sender, e); - private void BtnQuickFailedFilter_Click(object sender, RoutedEventArgs e) - => BtnFailedOnlyFilter_Click(sender, e); - private void BtnQuickHotSort_Click(object sender, RoutedEventArgs e) { _sortConversationsByRecent = false; @@ -3384,29 +3397,7 @@ public partial class ChatWindow : Window private void UpdateConversationFailureFilterUi() { - if (BtnFailedOnlyFilter == null || FailedOnlyFilterLabel == null) - return; - - BtnFailedOnlyFilter.Background = _failedOnlyFilter - ? BrushFromHex("#FEE2E2") - : Brushes.Transparent; - BtnFailedOnlyFilter.BorderBrush = _failedOnlyFilter - ? BrushFromHex("#FCA5A5") - : Brushes.Transparent; - BtnFailedOnlyFilter.BorderThickness = _failedOnlyFilter - ? new Thickness(1) - : new Thickness(0); - FailedOnlyFilterLabel.Foreground = _failedOnlyFilter - ? BrushFromHex("#991B1B") - : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); - FailedOnlyFilterLabel.Text = _failedConversationCount > 0 - ? $"실패 {_failedConversationCount}" - : "실패"; - BtnFailedOnlyFilter.ToolTip = _failedOnlyFilter - ? "실패 대화만 표시 중" - : _failedConversationCount > 0 - ? $"실패한 에이전트 실행이 있는 대화 {_failedConversationCount}개 보기" - : "실패한 에이전트 실행이 있는 대화만 보기"; + _failedOnlyFilter = false; UpdateSidebarModeMenu(); } @@ -4624,7 +4615,7 @@ public partial class ChatWindow : Window } // 슬래시 팝업이 열려 있으면 선택된 항목 실행 - if (SlashPopup.IsOpen && _slashSelectedIndex >= 0) + if (SlashPopup.IsOpen && _slashPalette.SelectedIndex >= 0) { e.Handled = true; ExecuteSlashSelectedItem(); @@ -4763,14 +4754,8 @@ public partial class ChatWindow : Window // ─── 슬래시 명령어 ──────────────────────────────────────────────────── - // ── 슬래시 명령어 팝업 페이징 ── - private int SlashPageSize => Math.Clamp(_settings.Settings.Llm.SlashPopupPageSize, 3, 20); - private List<(string Cmd, string Label, bool IsSkill)> _slashAllMatches = []; - private int _slashPageOffset = 0; - private int _slashSelectedIndex = -1; // 팝업 내 키보드 선택 인덱스 (페이지 내 상대) - - // ── 슬래시 명령어 칩 ── - private string? _activeSlashCmd = null; + // ── 슬래시 명령어 팝업 상태 ── + private readonly SlashPaletteState _slashPalette = new(); // ── 슬래시 명령어 (탭별 분류) ── @@ -4875,7 +4860,7 @@ public partial class ChatWindow : Window var text = InputBox.Text; // 칩이 활성화된 상태에서 사용자가 /를 타이핑하면 칩 해제 - if (_activeSlashCmd != null && text.StartsWith("/")) + if (_slashPalette.ActiveCommand != null && text.StartsWith("/")) HideSlashChip(restoreText: false); if (text.StartsWith("/") && !text.Contains(' ')) @@ -4913,9 +4898,8 @@ public partial class ChatWindow : Window .ToList(); } - _slashAllMatches = matches; - _slashPageOffset = 0; - _slashSelectedIndex = -1; + _slashPalette.Matches = matches; + _slashPalette.SelectedIndex = -1; RenderSlashPage(); SlashPopup.IsOpen = true; RefreshDraftQueueUi(); @@ -4935,14 +4919,12 @@ public partial class ChatWindow : Window return skill.IsAvailable ? baseLabel : $"{baseLabel} {skill.UnavailableHint}"; } - /// 현재 페이지의 슬래시 명령어 항목을 렌더링합니다. + /// 현재 슬래시 명령어 항목을 스크롤 리스트로 렌더링합니다. private void RenderSlashPage() { SlashItems.Items.Clear(); - var total = _slashAllMatches.Count; - var start = _slashPageOffset; - var end = Math.Min(start + SlashPageSize, total); - var totalSkills = _slashAllMatches.Count(x => x.IsSkill); + var total = _slashPalette.Matches.Count; + var totalSkills = _slashPalette.Matches.Count(x => x.IsSkill); var totalCommands = total - totalSkills; var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant(); @@ -4954,28 +4936,18 @@ public partial class ChatWindow : Window _ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개", }; - // 위 화살표 - if (start > 0) + for (int i = 0; i < total; i++) { - SlashNavUp.Visibility = Visibility.Visible; - SlashNavUpText.Text = $"▲ 위로 {start}개"; - } - else - SlashNavUp.Visibility = Visibility.Collapsed; - - // 아이템 렌더링 - for (int i = start; i < end; i++) - { - var (cmd, label, isSkill) = _slashAllMatches[i]; - var capturedCmd = cmd; - var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; + var (cmd, label, isSkill) = _slashPalette.Matches[i]; + var capturedCmd = cmd; + var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; var skillAvailable = skillDef?.IsAvailable ?? true; var isFav = _settings.Settings.Llm.FavoriteSlashCommands .Contains(cmd, StringComparer.OrdinalIgnoreCase); - var pageLocalIndex = i - start; - var isSelected = pageLocalIndex == _slashSelectedIndex; + var absoluteIndex = i; + var isSelected = absoluteIndex == _slashPalette.SelectedIndex; var hoverBrushItem = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.LightGray; var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent; var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; @@ -4985,14 +4957,14 @@ public partial class ChatWindow : Window var item = new Border { - Background = isSelected ? hoverBrushItem : itemBg, + Background = isSelected ? hoverBrushItem : itemBg, BorderBrush = isSelected ? accent : borderBrush, BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(10), - Padding = new Thickness(10, 8, 10, 8), - Margin = new Thickness(0, 0, 0, 6), - Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow, - Opacity = skillAvailable ? 1.0 : 0.5, + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 0, 6), + Cursor = skillAvailable ? Cursors.Hand : Cursors.Arrow, + Opacity = skillAvailable ? 1.0 : 0.5, }; var itemGrid = new Grid(); itemGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); @@ -5002,17 +4974,17 @@ public partial class ChatWindow : Window var titleRow = new StackPanel { Orientation = Orientation.Horizontal }; titleRow.Children.Add(new TextBlock { - Text = isSkill ? "\uE768" : "\uE9CE", + Text = isSkill ? "\uE768" : "\uE9CE", FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 11, + FontSize = 11, Foreground = skillAvailable ? accent : secondaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0), }); titleRow.Children.Add(new TextBlock { - Text = cmd, - FontSize = 13, + Text = cmd, + FontSize = 13, FontWeight = FontWeights.SemiBold, Foreground = skillAvailable ? primaryText : secondaryText, VerticalAlignment = VerticalAlignment.Center, @@ -5038,9 +5010,9 @@ public partial class ChatWindow : Window { titleRow.Children.Add(new TextBlock { - Text = " \uE735", + Text = " \uE735", FontFamily = new FontFamily("Segoe MDL2 Assets"), - FontSize = 10.5, + FontSize = 10.5, Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)), VerticalAlignment = VerticalAlignment.Center, }); @@ -5048,8 +5020,8 @@ public partial class ChatWindow : Window leftStack.Children.Add(titleRow); leftStack.Children.Add(new TextBlock { - Text = label, - FontSize = 11.5, + Text = label, + FontSize = 11.5, Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(18, 2, 0, 0), @@ -5058,11 +5030,11 @@ public partial class ChatWindow : Window Grid.SetColumn(leftStack, 0); itemGrid.Children.Add(leftStack); - // 즐겨찾기 토글 별 아이콘 var favCapturedCmd = cmd; var favBtn = new Border { - Width = 24, Height = 24, + Width = 24, + Height = 24, CornerRadius = new CornerRadius(4), Background = Brushes.Transparent, Cursor = Cursors.Hand, @@ -5084,7 +5056,7 @@ public partial class ChatWindow : Window favBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0; favBtn.MouseLeftButtonDown += (_, me) => { - me.Handled = true; // 아이템 클릭 이벤트 방지 + me.Handled = true; ToggleSlashFavorite(favCapturedCmd); }; Grid.SetColumn(favBtn, 1); @@ -5096,39 +5068,26 @@ public partial class ChatWindow : Window { item.MouseEnter += (_, _) => { + _slashPalette.SelectedIndex = absoluteIndex; item.Background = hoverBrushItem; item.BorderBrush = accent; }; item.MouseLeave += (_, _) => { - item.Background = isSelected ? hoverBrushItem : itemBg; - item.BorderBrush = isSelected ? accent : borderBrush; + item.Background = absoluteIndex == _slashPalette.SelectedIndex ? hoverBrushItem : itemBg; + item.BorderBrush = absoluteIndex == _slashPalette.SelectedIndex ? accent : borderBrush; }; item.MouseLeftButtonDown += (_, _) => { - SlashPopup.IsOpen = false; - if (capturedCmd.Equals("/help", StringComparison.OrdinalIgnoreCase)) - { - InputBox.Text = ""; - ShowSlashHelpWindow(); - return; - } - // 칩 표시: 명령어를 칩으로, InputBox는 빈 텍스트로 - ShowSlashChip(capturedCmd); - InputBox.Focus(); + _slashPalette.SelectedIndex = absoluteIndex; + ExecuteSlashSelectedItem(); }; } + SlashItems.Items.Add(item); } - // 아래 화살표 - if (end < total) - { - SlashNavDown.Visibility = Visibility.Visible; - SlashNavDownText.Text = $"▼ 아래로 {total - end}개"; - } - else - SlashNavDown.Visibility = Visibility.Collapsed; + EnsureSlashSelectionVisible(); } /// 슬래시 팝업 마우스 휠 스크롤 처리. @@ -5141,51 +5100,38 @@ public partial class ChatWindow : Window /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. private void SlashPopup_ScrollByDelta(int delta) { - if (_slashAllMatches.Count == 0) return; - var pageItemCount = Math.Min(SlashPageSize, _slashAllMatches.Count - _slashPageOffset); + if (_slashPalette.Matches.Count == 0) + return; - if (delta > 0) // 위로 스크롤 (Up 키) + if (delta > 0) { - if (_slashSelectedIndex > 0) - _slashSelectedIndex--; - else if (_slashSelectedIndex == 0 && _slashPageOffset > 0) - { - _slashPageOffset = Math.Max(0, _slashPageOffset - 1); - _slashSelectedIndex = 0; - } + if (_slashPalette.SelectedIndex > 0) + _slashPalette.SelectedIndex--; } - else // 아래로 스크롤 (Down 키) + else { - if (_slashSelectedIndex < 0) - { - // 초기 상태: 첫 번째 항목 선택 - _slashSelectedIndex = 0; - } - else if (_slashSelectedIndex < pageItemCount - 1) - _slashSelectedIndex++; - else if (_slashPageOffset + SlashPageSize < _slashAllMatches.Count) - { - _slashPageOffset++; - _slashSelectedIndex = Math.Min(SlashPageSize - 1, - _slashAllMatches.Count - _slashPageOffset - 1); - } + if (_slashPalette.SelectedIndex < 0) + _slashPalette.SelectedIndex = 0; + else if (_slashPalette.SelectedIndex < _slashPalette.Matches.Count - 1) + _slashPalette.SelectedIndex++; } + RenderSlashPage(); } /// 키보드로 선택된 슬래시 아이템을 실행합니다. private void ExecuteSlashSelectedItem() { - var absoluteIdx = _slashPageOffset + _slashSelectedIndex; - if (absoluteIdx < 0 || absoluteIdx >= _slashAllMatches.Count) return; + var absoluteIdx = _slashPalette.SelectedIndex; + if (absoluteIdx < 0 || absoluteIdx >= _slashPalette.Matches.Count) return; - var (cmd, _, isSkill) = _slashAllMatches[absoluteIdx]; + var (cmd, _, isSkill) = _slashPalette.Matches[absoluteIdx]; var skillDef = isSkill ? SkillService.Find(cmd.TrimStart('/')) : null; var skillAvailable = skillDef?.IsAvailable ?? true; if (!skillAvailable) return; SlashPopup.IsOpen = false; - _slashSelectedIndex = -1; + _slashPalette.SelectedIndex = -1; if (cmd.Equals("/help", StringComparison.OrdinalIgnoreCase)) { @@ -5197,6 +5143,32 @@ public partial class ChatWindow : Window InputBox.Focus(); } + private void EnsureSlashSelectionVisible() + { + if (SlashScrollViewer == null || _slashPalette.SelectedIndex < 0) + return; + + if (VisualTreeHelper.GetChildrenCount(SlashItems) == 0) + return; + + if (VisualTreeHelper.GetChild(SlashItems, 0) is not FrameworkElement presenter) + return; + + if (VisualTreeHelper.GetChildrenCount(presenter) <= _slashPalette.SelectedIndex) + return; + + if (VisualTreeHelper.GetChild(presenter, _slashPalette.SelectedIndex) is not FrameworkElement item) + return; + + var bounds = item.TransformToAncestor(SlashScrollViewer) + .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); + + if (bounds.Top < 0) + SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + bounds.Top - 8); + else if (bounds.Bottom > SlashScrollViewer.ViewportHeight) + SlashScrollViewer.ScrollToVerticalOffset(SlashScrollViewer.VerticalOffset + (bounds.Bottom - SlashScrollViewer.ViewportHeight) + 8); + } + /// 슬래시 명령어 즐겨찾기를 토글하고 설정을 저장합니다. private void ToggleSlashFavorite(string cmd) { @@ -5220,7 +5192,7 @@ public partial class ChatWindow : Window /// 슬래시 명령어 칩을 표시하고 InputBox를 비웁니다. private void ShowSlashChip(string cmd) { - _activeSlashCmd = cmd; + _slashPalette.ActiveCommand = cmd; SlashChipText.Text = cmd; SlashCommandChip.Visibility = Visibility.Visible; @@ -5235,9 +5207,9 @@ public partial class ChatWindow : Window /// true이면 InputBox에 명령어 텍스트를 복원합니다. private void HideSlashChip(bool restoreText = false) { - if (_activeSlashCmd == null) return; - var prev = _activeSlashCmd; - _activeSlashCmd = null; + if (_slashPalette.ActiveCommand == null) return; + var prev = _slashPalette.ActiveCommand; + _slashPalette.ActiveCommand = null; SlashCommandChip.Visibility = Visibility.Collapsed; InputBox.Padding = new Thickness(14, 10, 14, 10); if (restoreText) @@ -5521,7 +5493,7 @@ public partial class ChatWindow : Window // 공통 명령어 섹션 AddHelpSection(contentPanel, "공통 명령어", "모든 탭(Chat, Cowork, Code)에서 사용 가능", fg, fg2, accent, itemBg, hoverBg, ("/compact", "대화 컨텍스트를 즉시 압축하여 토큰 사용량을 줄입니다."), - ("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다."), + ("/status", "현재 탭/모델/권한/컨텍스트 상태를 보여줍니다. (/status test: 연결 진단)"), ("/new", "새 대화를 시작합니다."), ("/reset", "세션 컨텍스트를 초기화하고 새 대화를 시작합니다."), ("/model", "모델 선택 패널을 엽니다."), @@ -5727,7 +5699,11 @@ public partial class ChatWindow : Window _storage.Save(conv); AddMessageBubble("assistant", assistantText); ForceScrollToEnd(); + if (StatusTokens != null) + StatusTokens.Text = $"컨텍스트 {Services.TokenEstimator.Format(beforeTokens)} → {Services.TokenEstimator.Format(afterTokens)}"; SetStatus(condensed ? "컨텍스트 압축 완료" : "압축할 컨텍스트 없음", spinning: false); + RefreshConversationList(); + UpdateTaskSummaryIndicators(); } private void AppendLocalSlashResult(string runTab, string commandText, string assistantText) @@ -5837,6 +5813,49 @@ public partial class ChatWindow : Window return $"[{_activeTab}] {ServiceLabel(llm.Service)}/{model} · {permission} · msg {historyCount} · folder {folderName}"; } + private static string MaskEndpoint(string endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint)) + return "(미설정)"; + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + return endpoint; + + return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : ":" + uri.Port)}"; + } + + private static string NormalizeServiceLabel(string service) + { + return service.Trim().ToLowerInvariant() switch + { + "vllm" => "vLLM", + "gemini" => "Gemini", + "sigmoid" => "Claude", + _ => "Ollama", + }; + } + + private async Task BuildLlmRuntimeDiagnosisAsync() + { + var snapshot = _llm.GetRuntimeConnectionSnapshot(); + var (ok, message) = await _llm.TestConnectionAsync(); + + var lines = new List + { + "LLM 런타임 진단", + $"- 서비스: {NormalizeServiceLabel(snapshot.Service)}", + $"- 모델: {snapshot.Model}", + $"- 엔드포인트: {MaskEndpoint(snapshot.Endpoint)}", + $"- API 키: {(snapshot.HasApiKey ? "설정됨" : "미설정")}", + }; + + if (string.Equals(snapshot.Service, "vllm", StringComparison.OrdinalIgnoreCase)) + lines.Add($"- SSL 인증서 검증 우회: {(snapshot.AllowInsecureTls ? "ON" : "OFF")}"); + + lines.Add($"- 연결 결과: {(ok ? "성공" : "실패")} ({message})"); + return string.Join("\n", lines); + } + private bool IsMcpServerEnabled(McpServerEntry server) { if (_sessionMcpEnabledOverrides.TryGetValue(server.Name ?? "", out var overridden)) @@ -6760,8 +6779,8 @@ public partial class ChatWindow : Window var rawText = InputBox.Text.Trim(); // 슬래시 칩이 활성화된 경우 명령어 앞에 붙임 - var text = _activeSlashCmd != null - ? (_activeSlashCmd + " " + rawText).Trim() + var text = _slashPalette.ActiveCommand != null + ? (_slashPalette.ActiveCommand + " " + rawText).Trim() : rawText; HideSlashChip(restoreText: false); @@ -6793,7 +6812,15 @@ public partial class ChatWindow : Window } if (string.Equals(slashSystem, "__STATUS__", StringComparison.Ordinal)) { - AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText()); + var (statusAction, _) = ParseGenericAction(displayText ?? "", "/status"); + if (statusAction is "test" or "diag" or "connection" or "connect") + { + var diagnosis = await BuildLlmRuntimeDiagnosisAsync(); + AppendLocalSlashResult(_activeTab, "/status", diagnosis); + return; + } + + AppendLocalSlashResult(_activeTab, "/status", BuildSlashStatusText() + "\n(연결 점검: /status test)"); return; } if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal)) @@ -6801,17 +6828,17 @@ public partial class ChatWindow : Window var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions"); if (TryApplyPermissionModeFromAction(permAction, out var appliedMode)) { - AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {appliedMode}로 변경했습니다.\n{BuildPermissionStatusText()}"); + AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(appliedMode)}({appliedMode})로 변경했습니다.\n{BuildPermissionStatusText()}"); return; } if (permAction == "status") { - AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|auto|deny|status"); + AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions default|acceptedits|plan|bypass|dontask|deny|status"); return; } - OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|auto|deny|status"); + OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions default|acceptedits|plan|bypass|dontask|deny|status"); return; } if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal)) @@ -6819,17 +6846,17 @@ public partial class ChatWindow : Window var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools"); if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode)) { - AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {allowedMode}로 변경했습니다.\n{BuildPermissionStatusText()}"); + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(allowedMode)}({allowedMode})로 변경했습니다.\n{BuildPermissionStatusText()}"); return; } if (toolAction == "status") { - AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|auto|deny|status"); + AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools default|acceptedits|plan|bypass|dontask|deny|status"); return; } - OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|auto|deny|status"); + OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools default|acceptedits|plan|bypass|dontask|deny|status"); return; } if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal)) @@ -6995,7 +7022,7 @@ public partial class ChatWindow : Window if (string.Equals(slashSystem, "__SANDBOX_TOGGLE__", StringComparison.Ordinal)) { var mode = TogglePermissionModeFromSlash(); - AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {mode}로 변경했습니다."); + AppendLocalSlashResult(_activeTab, "/sandbox-toggle", $"권한 모드를 {PermissionModeCatalog.ToDisplayLabel(mode)}({mode})로 변경했습니다."); return; } if (string.Equals(slashSystem, "__RENAME__", StringComparison.Ordinal)) @@ -8095,24 +8122,18 @@ public partial class ChatWindow : Window private void UpdateConversationQuickStripUi() { - if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickFailedLabel == null || QuickHotLabel == null - || BtnQuickRunningFilter == null || BtnQuickFailedFilter == null || BtnQuickHotSort == null) + if (ConversationQuickStrip == null || QuickRunningLabel == null || QuickHotLabel == null + || BtnQuickRunningFilter == null || BtnQuickHotSort == null) return; - var showFailureFilter = GetAgentUiExpressionLevel() == "rich"; - if (BtnQuickFailedFilter != null) - BtnQuickFailedFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed; - var hasQuickSignal = _runningConversationCount > 0 - || _spotlightConversationCount > 0 - || (showFailureFilter && _failedConversationCount > 0); + || _spotlightConversationCount > 0; ConversationQuickStrip.Visibility = hasQuickSignal ? Visibility.Visible : Visibility.Collapsed; QuickRunningLabel.Text = _runningConversationCount > 0 ? $"진행 {_runningConversationCount}" : "진행"; - QuickFailedLabel.Text = _failedConversationCount > 0 ? $"실패 {_failedConversationCount}" : "실패"; QuickHotLabel.Text = _spotlightConversationCount > 0 ? $"활동 {_spotlightConversationCount}" : "활동"; BtnQuickRunningFilter.Background = _runningOnlyFilter ? BrushFromHex("#DBEAFE") : BrushFromHex("#F8FAFC"); @@ -8120,14 +8141,6 @@ public partial class ChatWindow : Window BtnQuickRunningFilter.BorderThickness = new Thickness(1); QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray); - 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"); BtnQuickHotSort.BorderBrush = !_sortConversationsByRecent ? BrushFromHex("#C4B5FD") : BrushFromHex("#E5E7EB"); BtnQuickHotSort.BorderThickness = new Thickness(1); @@ -10470,7 +10483,7 @@ public partial class ChatWindow : Window if (e.Key == Key.Escape) { SlashPopup.IsOpen = false; - _slashSelectedIndex = -1; + _slashPalette.SelectedIndex = -1; e.Handled = true; } else if (e.Key == Key.Up) @@ -10483,7 +10496,7 @@ public partial class ChatWindow : Window SlashPopup_ScrollByDelta(-120); // 아래로 1칸 e.Handled = true; } - else if (e.Key == Key.Enter && _slashSelectedIndex >= 0) + else if (e.Key == Key.Enter && _slashPalette.SelectedIndex >= 0) { e.Handled = true; ExecuteSlashSelectedItem(); @@ -12159,7 +12172,7 @@ public partial class ChatWindow : Window private void UpdateWatermarkVisibility() { // 슬래시 칩이 활성화되어 있으면 워터마크 숨기기 (겹침 방지) - if (_activeSlashCmd != null) + if (_slashPalette.ActiveCommand != null) { InputWatermark.Visibility = Visibility.Collapsed; return; @@ -12341,10 +12354,12 @@ public partial class ChatWindow : Window }; private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch { - "ask" => "Plan", - "plan" => "Auto", - "auto" => "Deny", - _ => "Ask", + "deny" => "Default", + "default" => "AcceptEdits", + "acceptedits" => "Plan", + "plan" => "BypassPermissions", + "bypasspermissions" => "DontAsk", + _ => "Deny", }; private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch { @@ -12422,7 +12437,7 @@ public partial class ChatWindow : Window BtnInlineFastMode.Content = $"Fast mode · {(llm.FreeTierMode ? "On" : "Off")}"; BtnInlineReasoning.Content = $"Reasoning · {ReasoningLabel(llm.AgentDecisionLevel)}"; BtnInlinePlanMode.Content = $"Plan mode · {PlanModeLabel(llm.PlanMode)}"; - BtnInlinePermission.Content = $"Permission · {PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission)}"; + BtnInlinePermission.Content = $"Permission · {PermissionModeCatalog.ToDisplayLabel(llm.FilePermission)}"; BtnInlineSkill.Content = $"Skill system · {(llm.EnableSkillSystem ? "On" : "Off")}"; BtnInlineCommandBrowser.Content = "명령/스킬 브라우저"; @@ -12443,13 +12458,43 @@ public partial class ChatWindow : Window private void OpenAgentSettingsWindow() { + if (_agentSettingsWindow != null) + { + try + { + if (_agentSettingsWindow.IsVisible) + { + _agentSettingsWindow.Activate(); + return; + } + } + catch + { + // ignore stale window instance + } + _agentSettingsWindow = null; + } + var win = new AgentSettingsWindow(_settings) { - Owner = this, + Owner = IsLoaded && IsVisible ? this : null, }; + _agentSettingsWindow = win; + win.Closed += (_, _) => _agentSettingsWindow = null; win.Resources.MergedDictionaries.Add(Resources); - var changed = win.ShowDialog() == true; + bool changed; + try + { + changed = win.ShowDialog() == true; + } + catch + { + // 모달 창 오픈에 실패하면 일반 창으로라도 설정 접근을 보장 + win.Show(); + win.Activate(); + return; + } if (!changed) return; @@ -14379,7 +14424,7 @@ public partial class ChatWindow : Window if (InputBox == null) return; - if (!string.IsNullOrEmpty(_activeSlashCmd)) + if (!string.IsNullOrEmpty(_slashPalette.ActiveCommand)) HideSlashChip(); InputBox.Text = seedInput; @@ -15777,9 +15822,9 @@ public partial class ChatWindow : Window return button; } - actions.Children.Add(BuildPermissionButton("Ask", "#F8FAFC", "#CBD5E1", "#334155", "Ask")); - actions.Children.Add(BuildPermissionButton("Auto", "#FFF7ED", "#FDBA74", "#C2410C", "Auto")); - actions.Children.Add(BuildPermissionButton("Deny", "#FEF2F2", "#FCA5A5", "#991B1B", "Deny")); + actions.Children.Add(BuildPermissionButton("기본", "#F8FAFC", "#CBD5E1", "#334155", PermissionModeCatalog.Default)); + actions.Children.Add(BuildPermissionButton("적극", "#FFF7ED", "#FDBA74", "#C2410C", PermissionModeCatalog.AcceptEdits)); + actions.Children.Add(BuildPermissionButton("차단", "#FEF2F2", "#FCA5A5", "#991B1B", PermissionModeCatalog.Deny)); actions.Children.Add(BuildPermissionButton("해제", "#F3F4F6", "#D1D5DB", "#374151", null, margin: false)); return actions; } @@ -16034,7 +16079,7 @@ public partial class ChatWindow : Window { new TextBlock { - Text = $"현재 권한 모드 · {permissionSummary.EffectiveMode}", + Text = $"현재 권한 모드 · {PermissionModeCatalog.ToDisplayLabel(permissionSummary.EffectiveMode)}", FontWeight = FontWeights.SemiBold, Foreground = string.Equals(permissionSummary.RiskLevel, "high", StringComparison.OrdinalIgnoreCase) ? BrushFromHex("#C2410C") @@ -16165,3 +16210,4 @@ public partial class ChatWindow : Window return new System.Windows.Media.SolidColorBrush(c); } } + diff --git a/src/AxCopilot/Views/SlashPaletteState.cs b/src/AxCopilot/Views/SlashPaletteState.cs new file mode 100644 index 0000000..87b41af --- /dev/null +++ b/src/AxCopilot/Views/SlashPaletteState.cs @@ -0,0 +1,23 @@ +namespace AxCopilot.Views; + +internal sealed class SlashPaletteState +{ + public List<(string Cmd, string Label, bool IsSkill)> Matches { get; set; } = []; + + public int SelectedIndex { get; set; } = -1; + + public string? ActiveCommand { get; set; } + + public void ResetMatches(List<(string Cmd, string Label, bool IsSkill)> matches) + { + Matches = matches; + SelectedIndex = -1; + } + + public void Clear() + { + Matches = []; + SelectedIndex = -1; + ActiveCommand = null; + } +}