Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbplugins</groupId>
<artifactId>netbeans-plugin-claude-code-gui</artifactId>
<version>1.2.11-SNAPSHOT</version>
<version>1.2.12-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Plugin Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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}.
*
* <p>Runs on a daemon thread; sets lifecycle to WORKING at start and
* READY on completion.
*
Expand All @@ -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<EditMode> 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 {
Expand All @@ -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<EditMode> 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
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,15 +535,15 @@ public CompletableFuture<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,43 @@
* identifier in {@link ClaudeSessionModel#EDIT_MODE_REGISTRY}, in serialized
* settings, and in logging.
*
* <p>Constants are ordered from <em>least</em> to <em>most</em> permissive, so
* ordinal comparisons ({@code >=}, {@code <=}) express permission thresholds naturally.
*
* <p>Screen-text mapping (used by {@code ScreenContentDetector.detectEditMode}):
* <ul>
* <li>{@link #PLAN} — bottom line contains {@code "plan mode"} or {@code "plan-mode"}</li>
* <li>{@link #DEFAULT} — bottom line starts with {@code " esc to interrupt"}
* (two leading spaces)</li>
* <li>{@link #ACCEPT_EDITS} — bottom line contains {@code "accept edits"}</li>
* <li>{@link #AUTO} — bottom line contains {@code "auto mode"};
* available on Max/Team/Enterprise/API plans (Claude Code 2.1.83+)</li>
* <li>{@link #BYPASS_PERMISSIONS} — bottom line contains {@code "bypass permissions"};
* appears when Claude was launched with {@code --dangerously-skip-permissions}</li>
* <li>{@link #DEFAULT} — bottom line starts with {@code " esc to interrupt"}
* (two leading spaces)</li>
* </ul>
*/
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}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,9 @@ public Optional<EditMode> detectEditMode(List<String> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> 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
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<EditMode> result = detector.detectEditMode(lines);
assertTrue(result.isPresent());
assertEquals(EditMode.AUTO, result.get());
}

@org.junit.jupiter.api.Test
void detectEditModeAutoModeCaseInsensitive() {
List<String> lines = List.of("Some output", "Auto Mode on | esc to interrupt");
java.util.Optional<EditMode> result = detector.detectEditMode(lines);
assertTrue(result.isPresent());
assertEquals(EditMode.AUTO, result.get());
}

@org.junit.jupiter.api.Test
void detectEditModeEmptyWhenNullOrEmpty() {
assertTrue(detector.detectEditMode(null).isEmpty());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,57 @@

/**
* 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).
*
* <p>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 {

@Test
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");
}

Expand All @@ -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");
}

Expand All @@ -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");
}
}
Loading