diff --git a/README.md b/README.md index d74ea98..a4c38bd 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,7 @@ public class MyHandler : IActionHandler | 슬래시 네비게이션 입력 보강 | InputBox 포커스 상태에서도 방향키/Page/Home/End/Tab이 슬래시 목록 탐색에 즉시 반영되도록 키 처리 경로를 통합하고, 모든 그룹 접힘 상태에서 휠 스크롤 fallback을 추가 | | 사내/사외 모드 회귀 보강 | operationMode 전환 직후 WebSearch 동작 반영과 URL 판별 경계(HTTP/파일/mailto) 테스트를 추가해 내부 차단 정책의 즉시성/정확성을 강화 | | AX Agent 설정창 오픈 안정화 | `ToggleSwitch`를 전역 리소스로 승격해 AX Agent 창 초기화 시 리소스 누락 예외를 방지하고, AX Agent 설정창에는 테마 사전만 안전 주입하도록 오픈 경로를 보강 | +| 권한 순환/슬래시 스크롤 체감 보강 | `claw-code` 기준으로 권한 순환에서 고위험 `질문 없이 진행`을 기본 순환에서 분리하고, `/` 팝업 휠 스크롤 시 뷰포트 기준 선택 동기화를 추가해 스크롤 사용성을 개선 | | Slash palette 상태 분리 시작 | `ChatWindow`에 몰려 있던 slash 상태를 `SlashPaletteState`로 분리해 이후 Codex/Claude형 composer 개편 기반 마련 | | 런처 이미지 미리보기 추가 | `#` 클립보드 이미지 항목에서 `Shift+Enter`로 전용 미리보기 창을 열고, 줌·원본 해상도 확인·PNG/JPEG/BMP 저장·클립보드 복사를 지원 | | 검증 | `dotnet build` 경고 0 / 오류 0, `dotnet test` 436 passed / 0 failed | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a2cabd8..d0db422 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3712,3 +3712,33 @@ else: ### 3) 품질 게이트 - `dotnet build src/AxCopilot/AxCopilot.csproj -c Debug -p:UseSharedCompilation=false -nodeReuse:false` 통과 (경고 0, 오류 0). - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Debug --filter "ChatWindowSlashPolicyTests|OperationModePolicyTests|OperationModeReadinessTests"` 통과 (59 passed, 0 failed). + +## 2026-04-04 추가 진행 기록 (연속 실행 45차: 권한 순환/슬래시 스크롤 체감 보강) + +업데이트: 2026-04-04 17:27 (KST) + +### 1) 권한 모드 기본 순환 정렬 +- `claw-code`의 코어 모드 순환 기준에 맞춰 기본 순환을 `Deny → Default → AcceptEdits → Plan → BypassPermissions → Deny`로 고정. +- `DontAsk`는 고위험 모드로 분리해 기본 순환(인라인/슬래시/설정창 버튼)에서 제외하고 명시 선택으로만 진입하도록 조정. +- 반영 위치: + - `ChatWindow.NextPermission(...)` + - `AgentSettingsWindow.BtnPermissionMode_Click(...)` + +### 2) 슬래시 팝업 휠 스크롤 반응 보강 +- `/` 팝업 휠 입력 시: + 1. `ScrollViewer` 오프셋을 실제로 이동 + 2. 뷰포트 상단에 가장 가까운 항목으로 선택 인덱스를 동기화 + 3. 선택 강조/가시화 갱신 +- 효과: + - 터치패드/마우스에서 스크롤 체감이 더 자연스러워지고, 선택 하이라이트와 실제 스크롤 위치 불일치가 완화됨. + +### 3) 회귀 테스트 추가 +- `ChatWindowSlashPolicyTests.NextPermission_ShouldCycleCoreModesAndReturnToDeny` 추가. +- 검증 케이스: + - `BypassPermissions -> Deny` + - `DontAsk -> Deny` + - 코어 순환 전 구간. + +### 4) 품질 게이트 +- `dotnet build src/AxCopilot/AxCopilot.csproj -c Debug -p:UseSharedCompilation=false -nodeReuse:false` 통과 (경고 0, 오류 0). +- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Debug -p:UseSharedCompilation=false -nodeReuse:false --filter "ChatWindowSlashPolicyTests|OperationModePolicyTests|OperationModeReadinessTests"` 통과 (65 passed, 0 failed). diff --git a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs index edc2dc7..5206538 100644 --- a/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs +++ b/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs @@ -1,11 +1,28 @@ using AxCopilot.Views; using FluentAssertions; using Xunit; +using System.Reflection; namespace AxCopilot.Tests.Views; public class ChatWindowSlashPolicyTests { + [Theory] + [InlineData("Deny", "Default")] + [InlineData("Default", "AcceptEdits")] + [InlineData("AcceptEdits", "Plan")] + [InlineData("Plan", "BypassPermissions")] + [InlineData("BypassPermissions", "Deny")] + [InlineData("DontAsk", "Deny")] + public void NextPermission_ShouldCycleCoreModesAndReturnToDeny(string current, string expected) + { + var method = typeof(ChatWindow).GetMethod("NextPermission", BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + + var result = method!.Invoke(null, new object[] { current }) as string; + result.Should().Be(expected); + } + [Fact] public void BuildVerifySystemPrompt_ShouldContainStructuredSections() { @@ -40,9 +57,12 @@ public class ChatWindowSlashPolicyTests [InlineData("model", "model", "")] [InlineData("ask", "ask", "")] [InlineData("reconnect all", "reconnect", "all")] + [InlineData("/compact", "open", "")] + [InlineData("compact now", "compact", "now")] public void ParseGenericAction_ShouldParseExpected(string displayText, string expectedAction, string expectedArgument) { - var (action, argument) = ChatWindow.ParseGenericAction(displayText, "/settings"); + var command = displayText.StartsWith("/compact", StringComparison.OrdinalIgnoreCase) ? "/compact" : "/settings"; + var (action, argument) = ChatWindow.ParseGenericAction(displayText, command); action.Should().Be(expectedAction); argument.Should().Be(expectedArgument); @@ -60,6 +80,19 @@ public class ChatWindowSlashPolicyTests argument.Should().Be(expectedArgument); } + [Theory] + [InlineData("/status", "open", "")] + [InlineData("test", "test", "")] + [InlineData("connection", "connection", "")] + [InlineData("diag now", "diag", "now")] + public void ParseGenericAction_ForStatus_ShouldParseExpected(string displayText, string expectedAction, string expectedArgument) + { + var (action, argument) = ChatWindow.ParseGenericAction(displayText, "/status"); + + action.Should().Be(expectedAction); + argument.Should().Be(expectedArgument); + } + [Theory] [InlineData(false, "stdio", true, false, null, "Disabled")] [InlineData(true, "stdio", false, false, null, "Enabled")] diff --git a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs index 559eba2..a01c4dc 100644 --- a/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs +++ b/src/AxCopilot/Views/AgentSettingsWindow.xaml.cs @@ -328,7 +328,9 @@ public partial class AgentSettingsWindow : Window PermissionModeCatalog.Default => PermissionModeCatalog.AcceptEdits, PermissionModeCatalog.AcceptEdits => PermissionModeCatalog.Plan, PermissionModeCatalog.Plan => PermissionModeCatalog.BypassPermissions, - PermissionModeCatalog.BypassPermissions => PermissionModeCatalog.DontAsk, + // 기본 순환에서는 코어 모드만 순환하고 dontAsk는 명시 선택으로만 진입. + PermissionModeCatalog.BypassPermissions => PermissionModeCatalog.Deny, + PermissionModeCatalog.DontAsk => PermissionModeCatalog.Deny, _ => PermissionModeCatalog.Deny, }; RefreshModeLabels(); diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index fcde31d..f518e97 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -5574,6 +5574,40 @@ public partial class ChatWindow : Window _slashPalette.SelectedIndex = visibleOrder[currentPosition + 1]; } + private int? FindSlashIndexClosestToViewportTop() + { + if (SlashScrollViewer == null || _slashVisibleAbsoluteOrder.Count == 0) + return null; + + var bestIndex = -1; + var bestDistance = double.MaxValue; + foreach (var absoluteIndex in _slashVisibleAbsoluteOrder) + { + if (!_slashVisibleItemByAbsoluteIndex.TryGetValue(absoluteIndex, out var item)) + continue; + + try + { + var bounds = item.TransformToAncestor(SlashScrollViewer) + .TransformBounds(new Rect(0, 0, item.ActualWidth, item.ActualHeight)); + + // 뷰포트 상단에 가장 가까운 가시 항목을 선택 기준으로 사용. + var distance = Math.Abs(bounds.Top); + if (distance < bestDistance && bounds.Bottom >= 0) + { + bestDistance = distance; + bestIndex = absoluteIndex; + } + } + catch + { + // 레이아웃 갱신 중 transform 예외는 무시. + } + } + + return bestIndex >= 0 ? bestIndex : null; + } + /// 슬래시 팝업을 Delta 방향으로 스크롤합니다. private void SlashPopup_ScrollByDelta(int delta) { @@ -5587,11 +5621,24 @@ public partial class ChatWindow : Window return; } - var steps = Math.Max(1, Math.Abs(delta) / 120); + // 터치패드/마우스 환경 모두에서 체감이 유사하도록 스크롤뷰도 함께 이동. + if (SlashScrollViewer != null) + { + var target = Math.Max(0, Math.Min( + SlashScrollViewer.ScrollableHeight, + SlashScrollViewer.VerticalOffset - (delta / 3.0))); + SlashScrollViewer.ScrollToVerticalOffset(target); + } + + var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(delta) / 120.0)); var direction = delta > 0 ? -1 : 1; for (var i = 0; i < steps; i++) MoveSlashSelection(direction); + var viewportTopIndex = FindSlashIndexClosestToViewportTop(); + if (viewportTopIndex.HasValue) + _slashPalette.SelectedIndex = viewportTopIndex.Value; + UpdateSlashSelectionVisualState(); EnsureSlashSelectionVisible(); } @@ -12842,7 +12889,10 @@ public partial class ChatWindow : Window "default" => "AcceptEdits", "acceptedits" => "Plan", "plan" => "BypassPermissions", - "bypasspermissions" => "DontAsk", + // claw-code 기준: 기본 순환은 코어 모드까지만 이동하고, + // dontAsk는 명시 선택(고급 모드)으로만 진입. + "bypasspermissions" => "Deny", + "dontask" => "Deny", _ => "Deny", }; private static string ServiceLabel(string service) => (service ?? "").ToLowerInvariant() switch