From 4c75ca31505eafd819aecc43050d7df069215104 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sun, 24 May 2026 21:52:22 +0300 Subject: [PATCH] Added "Auto Mode" support (Claude Code 2.1.83+) Auto mode is a classifier-based permission mode introduced in CC 2.1.83 (Max/Team/Enterprise/API plans) that auto-approves safe tool calls including all file edits. Changes: - EditMode enum reordered by permissiveness (PLAN < DEFAULT < ACCEPT_EDITS < AUTO < BYPASS_PERMISSIONS); AUTO added between ACCEPT_EDITS and BYPASS_PERMISSIONS - Hook auto-allow logic replaced with ordinal-based >= comparisons: AUTO+ unconditional allow, ACCEPT_EDITS+ conditional (inside project) - "Auto Mode" added as a static 4th entry in the edit-mode combo; "Bypass Permissions" remains dynamic and appears after Auto Mode - sendShiftTabsUntilMode() refactored; when AUTO is selected but not found (old CC), falls back to BYPASS_PERMISSIONS then ACCEPT_EDITS - ScreenContentDetector detects "auto mode" in TUI footer Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../controller/ClaudeSessionController.java | 55 +++++++++++++------ .../claudecodegui/mcp/NetBeansMCPHandler.java | 10 ++-- .../claudecodegui/model/EditMode.java | 25 +++++++-- .../process/ScreenContentDetector.java | 3 + .../claudecodegui/ui/ClaudeSessionTab.java | 6 +- .../mcp/NetBeansMCPHandlerHookTest.java | 27 +++++++++ .../process/ScreenContentDetectorTest.java | 21 +++++++ .../ui/ClaudeSessionTabBypassComboTest.java | 30 ++++++---- 10 files changed, 136 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef03a0f..cc95fb21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added "Auto Mode" to the edit-mode selector (Claude Code 2.1.83+): file edits in auto mode are auto-approved; when auto mode is selected on an older Claude Code version, the plugin falls back to Bypass Permissions or Accept on Edit - Fixed diff panel appearing for file edits despite bypassPermissions or acceptEdits mode when Claude's working directory was a subdirectory of the session root - Fixed occasional ConcurrentModificationException when the screen-poll timer reads the terminal buffer concurrently with PTY writes - Fixed tab title showing "Claude Code" for sessions opened in non-project directories — now shows the directory name diff --git a/pom.xml b/pom.xml index d8beefbd..72fe8ecf 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.2.11-SNAPSHOT + 1.2.12-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index 2335e3cf..bdd835ad 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -716,6 +716,10 @@ public void onEditModeComboChanged(EditMode newMode) { * {@code targetMode}, or until 5 attempts are exhausted. * 5 attempts cover the full 4-mode cycle plus one extra in case of lag. * + *

When {@code targetMode} is {@link EditMode#AUTO} and the mode cannot be + * reached (older Claude Code that does not support auto mode), falls back to + * {@link EditMode#BYPASS_PERMISSIONS} if available, then {@link EditMode#ACCEPT_EDITS}. + * *

Runs on a daemon thread; sets lifecycle to WORKING at start and * READY on completion. * @@ -729,25 +733,13 @@ private void sendShiftTabsUntilMode(EditMode targetMode) { try { LOG.info("sendShiftTabsUntilMode: target=" + targetMode + " currentModel=" + model.getEditMode()); - for (int attempt = 0; attempt < 5; attempt++) { - connector.write(new byte[]{0x1b, '[', 'Z'}); - Thread.sleep(200); - Optional detected = - screenContentDetector.detectEditMode(screenLines.get()); - LOG.fine("sendShiftTabsUntilMode: attempt=" + attempt - + " detected=" + detected.map(EditMode::key).orElse("(empty)")); - if (detected.isPresent() && detected.get() == targetMode) { - LOG.fine("sendShiftTabsUntilMode: reached target on attempt=" + attempt); - return; - } - // DEFAULT mode has no idle-screen marker — empty detection means we are in default - if (targetMode == EditMode.DEFAULT && detected.isEmpty()) { - LOG.fine("sendShiftTabsUntilMode: no mode marker → treating as default on attempt=" + attempt); - return; - } + if (trySendShiftTabsUntilMode(targetMode)) return; + // AUTO mode not found — older CC doesn't support it; try fallbacks + if (targetMode == EditMode.AUTO) { + LOG.warning("sendShiftTabsUntilMode: auto mode not available, trying fallbacks"); + if (trySendShiftTabsUntilMode(EditMode.BYPASS_PERMISSIONS)) return; + trySendShiftTabsUntilMode(EditMode.ACCEPT_EDITS); } - LOG.warning("sendShiftTabsUntilMode: did not reach " + targetMode - + " after 5 attempts"); } catch (IOException | InterruptedException ex) { LOG.warning("sendShiftTabsUntilMode failed: " + ex.getMessage()); } finally { @@ -759,6 +751,33 @@ private void sendShiftTabsUntilMode(EditMode targetMode) { t.start(); } + /** + * Inner loop: sends up to 5 Shift+Tab presses and returns {@code true} when + * {@code targetMode} is detected on screen, {@code false} if not reached. + */ + private boolean trySendShiftTabsUntilMode(EditMode targetMode) + throws IOException, InterruptedException { + for (int attempt = 0; attempt < 5; attempt++) { + connector.write(new byte[]{0x1b, '[', 'Z'}); + Thread.sleep(200); + Optional detected = + screenContentDetector.detectEditMode(screenLines.get()); + LOG.fine("trySendShiftTabsUntilMode: target=" + targetMode + " attempt=" + attempt + + " detected=" + detected.map(EditMode::key).orElse("(empty)")); + if (detected.isPresent() && detected.get() == targetMode) { + LOG.fine("trySendShiftTabsUntilMode: reached " + targetMode + " on attempt=" + attempt); + return true; + } + // DEFAULT mode has no idle-screen marker — empty detection means we are in default + if (targetMode == EditMode.DEFAULT && detected.isEmpty()) { + LOG.fine("trySendShiftTabsUntilMode: no mode marker → treating as default on attempt=" + attempt); + return true; + } + } + LOG.warning("trySendShiftTabsUntilMode: did not reach " + targetMode + " after 5 attempts"); + return false; + } + // ------------------------------------------------------------------------- // Model switching // ------------------------------------------------------------------------- diff --git a/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java index 4cf6e68e..c0c4cf04 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandler.java @@ -535,15 +535,15 @@ public CompletableFuture handlePreToolUse(String hookJson) { String filePath = getFilePath(toolInput); - // bypassPermissions: auto-allow everything regardless of location - // acceptEdits: auto-allow only if file is inside the session's confirmed directory - if (editMode == EditMode.BYPASS_PERMISSIONS - || (editMode == EditMode.ACCEPT_EDITS + // AUTO or higher (BYPASS_PERMISSIONS): auto-allow regardless of location + // ACCEPT_EDITS or higher (but below AUTO): auto-allow only inside the session root + if (editMode.ordinal() >= EditMode.AUTO.ordinal() + || (editMode.ordinal() >= EditMode.ACCEPT_EDITS.ordinal() && io.github.nbplugins.claudecodegui.ui.FileDiffOpener.isFileUnderDirectory(filePath, sessionRoot))) { LOGGER.info(editMode.key() + " mode — auto-allowing: " + filePath); return CompletableFuture.completedFuture(hookAllowJson()); } - // plan / ask / acceptEdits-outside: fall through to show diff dialog + // PLAN / DEFAULT / ACCEPT_EDITS-outside: fall through to show diff dialog String before = computeBefore(toolInput, filePath); String after = computeAfter(toolName, toolInput, before); diff --git a/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java b/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java index 0a89f1e1..1c3c7806 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java @@ -9,30 +9,43 @@ * identifier in {@link ClaudeSessionModel#EDIT_MODE_REGISTRY}, in serialized * settings, and in logging. * + *

Constants are ordered from least to most permissive, so + * ordinal comparisons ({@code >=}, {@code <=}) express permission thresholds naturally. + * *

Screen-text mapping (used by {@code ScreenContentDetector.detectEditMode}): *

*/ public enum EditMode { - /** Ask-on-edit — shows a diff dialog before every file change. */ - DEFAULT("default"), - - /** Plan mode — Claude proposes a plan before executing. */ + /** Plan mode — Claude proposes a plan; no file changes are auto-allowed. */ PLAN("plan"), + /** Default (ask-on-edit) — shows a diff dialog before every file change. */ + DEFAULT("default"), + /** * Accept-edits — auto-allows file changes inside the session's working * directory; shows a diff dialog for files outside. */ ACCEPT_EDITS("acceptEdits"), + /** + * Auto mode — a classifier auto-approves safe tool calls including all file + * edits, blocking only dangerous operations (mass deletion, data exfiltration, etc.). + * Available on Max/Team/Enterprise/API plans (Claude Code 2.1.83+). + * Detected on screen as {@code ⏵⏵ auto mode on (shift+tab to cycle)}. + */ + AUTO("auto"), + /** * Bypass-permissions — auto-allows all file changes regardless of location. * Active when Claude was launched with {@code --dangerously-skip-permissions}. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java index ae000c00..8c408c9c 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java @@ -489,6 +489,9 @@ public Optional detectEditMode(List lines) { if (lower.contains("bypass permissions")) { return Optional.of(EditMode.BYPASS_PERMISSIONS); } + if (lower.contains("auto mode")) { + return Optional.of(EditMode.AUTO); + } } // " esc to interrupt" with two leading spaces is the reliable Ask/default-mode signal. // Without leading spaces the screen is in an unknown / transitioning state. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java index 10bb86ab..767bdc89 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTab.java @@ -120,9 +120,9 @@ public class ClaudeSessionTab extends TopComponent // Edit mode constants // ------------------------------------------------------------------------- - /** Base edit modes always shown in the combo (indices 0–2). */ - static final String[] EDIT_MODE_LABELS = {"Plan Mode", "Ask on Edit", "Accept on Edit"}; - static final EditMode[] EDIT_MODE_VALUES = {EditMode.PLAN, EditMode.DEFAULT, EditMode.ACCEPT_EDITS}; + /** Base edit modes always shown in the combo (indices 0–3). */ + static final String[] EDIT_MODE_LABELS = {"Plan Mode", "Ask on Edit", "Accept on Edit", "Auto Mode"}; + static final EditMode[] EDIT_MODE_VALUES = {EditMode.PLAN, EditMode.DEFAULT, EditMode.ACCEPT_EDITS, EditMode.AUTO}; /** Label / value for the 4th entry added dynamically when bypass-permissions is detected. */ private static final String BYPASS_LABEL = "Bypass Permissions"; diff --git a/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java b/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java index 8091231a..51267bfe 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/mcp/NetBeansMCPHandlerHookTest.java @@ -72,6 +72,33 @@ void bypassPermissionsAutoAllowsFileOutsideProject() throws Exception { "bypassPermissions must allow files outside project, got: " + result); } + // ------------------------------------------------------------------------- + // auto mode — auto-allow regardless of location + // ------------------------------------------------------------------------- + + @Test + void autoModeAutoAllowsFileInsideProject() throws Exception { + ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.AUTO); + + CompletableFuture future = handler.handlePreToolUse(editJson(FILE_INSIDE)); + + assertTrue(future.isDone(), "Future must be completed immediately in auto mode"); + String result = future.get(1, TimeUnit.SECONDS); + assertTrue(result.contains("\"allow\""), "auto mode must allow files inside project, got: " + result); + } + + @Test + void autoModeAutoAllowsFileOutsideProject() throws Exception { + ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.AUTO); + + CompletableFuture future = handler.handlePreToolUse(editJson(FILE_OUTSIDE)); + + assertTrue(future.isDone(), "Future must be completed immediately in auto mode"); + String result = future.get(1, TimeUnit.SECONDS); + assertTrue(result.contains("\"allow\""), + "auto mode must allow files outside project (unconditional), got: " + result); + } + // ------------------------------------------------------------------------- // acceptEdits — auto-allow only inside cwd // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java index 48736400..91fb7525 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java @@ -213,6 +213,27 @@ void detectEditModeDefaultWhenEscToInterruptLeadingSpaces() { assertEquals(EditMode.DEFAULT, result.get()); } + @org.junit.jupiter.api.Test + void detectEditModeAutoMode() { + // Screen text from Claude Code 2.1.83+ with auto mode active + List lines = new ArrayList<>(List.of( + "Some output", + "⏵⏵ auto mode on (shift+tab to cycle) · ← for agents" + )); + for (int i = 0; i < 10; i++) lines.add(""); + java.util.Optional result = detector.detectEditMode(lines); + assertTrue(result.isPresent()); + assertEquals(EditMode.AUTO, result.get()); + } + + @org.junit.jupiter.api.Test + void detectEditModeAutoModeCaseInsensitive() { + List lines = List.of("Some output", "Auto Mode on | esc to interrupt"); + java.util.Optional result = detector.detectEditMode(lines); + assertTrue(result.isPresent()); + assertEquals(EditMode.AUTO, result.get()); + } + @org.junit.jupiter.api.Test void detectEditModeEmptyWhenNullOrEmpty() { assertTrue(detector.detectEditMode(null).isEmpty()); diff --git a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabBypassComboTest.java b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabBypassComboTest.java index 51f89b52..96380231 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabBypassComboTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/ui/ClaudeSessionTabBypassComboTest.java @@ -8,9 +8,12 @@ /** * Regression tests for the bypass-permissions combo entry: - * once detected, the 4th "Bypass Permissions" entry must remain visible + * once detected, the 5th "Bypass Permissions" entry must remain visible * when the user switches to another edit mode, and must only be removed * when a new session starts (STARTING lifecycle). + * + *

Base combo has 4 static entries: Plan Mode, Ask on Edit, Accept on Edit, Auto Mode. + * Bypass Permissions is added dynamically at index 4 when detected. */ class ClaudeSessionTabBypassComboTest { @@ -18,40 +21,44 @@ class ClaudeSessionTabBypassComboTest { void bypassEntry_appearsWhenBypassDetected() { ClaudeSessionTab tab = new ClaudeSessionTab(); assertEquals(ClaudeSessionTab.EDIT_MODE_VALUES.length, tab.editModeItemCount(), - "Initially combo should have 3 items"); + "Initially combo should have 4 items (Plan, Ask on Edit, Accept on Edit, Auto Mode)"); tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS); assertEquals(ClaudeSessionTab.EDIT_MODE_VALUES.length + 1, tab.editModeItemCount(), - "After BYPASS_PERMISSIONS, combo should have 4 items"); + "After BYPASS_PERMISSIONS, combo should have 5 items"); } @Test void bypassEntry_persistsAfterSwitchingToAnotherMode() { ClaudeSessionTab tab = new ClaudeSessionTab(); tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS); - assertEquals(4, tab.editModeItemCount()); + assertEquals(5, tab.editModeItemCount()); tab.onEditModeChanged(EditMode.PLAN); - assertEquals(4, tab.editModeItemCount(), + assertEquals(5, tab.editModeItemCount(), "Bypass entry must remain after switching to PLAN mode"); tab.onEditModeChanged(EditMode.DEFAULT); - assertEquals(4, tab.editModeItemCount(), + assertEquals(5, tab.editModeItemCount(), "Bypass entry must remain after switching to DEFAULT mode"); tab.onEditModeChanged(EditMode.ACCEPT_EDITS); - assertEquals(4, tab.editModeItemCount(), + assertEquals(5, tab.editModeItemCount(), "Bypass entry must remain after switching to ACCEPT_EDITS mode"); + + tab.onEditModeChanged(EditMode.AUTO); + assertEquals(5, tab.editModeItemCount(), + "Bypass entry must remain after switching to AUTO mode"); } @Test void bypassEntry_removedOnNewSession() { ClaudeSessionTab tab = new ClaudeSessionTab(); tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS); - assertEquals(4, tab.editModeItemCount()); + assertEquals(5, tab.editModeItemCount()); tab.onLifecycleChanged(SessionLifecycle.STARTING); - assertEquals(3, tab.editModeItemCount(), + assertEquals(4, tab.editModeItemCount(), "Bypass entry must be removed when a new session starts"); } @@ -61,7 +68,8 @@ void bypassEntry_notAddedWithoutBypassSession() { tab.onEditModeChanged(EditMode.PLAN); tab.onEditModeChanged(EditMode.DEFAULT); tab.onEditModeChanged(EditMode.ACCEPT_EDITS); - assertEquals(3, tab.editModeItemCount(), + tab.onEditModeChanged(EditMode.AUTO); + assertEquals(4, tab.editModeItemCount(), "Bypass entry must not appear in a non-bypass session"); } @@ -70,7 +78,7 @@ void bypassEntry_notAddedTwice() { ClaudeSessionTab tab = new ClaudeSessionTab(); tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS); tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS); - assertEquals(4, tab.editModeItemCount(), + assertEquals(5, tab.editModeItemCount(), "Bypass entry must not be added twice"); } }