diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40db018e..62bee033 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Added "Bypass Permissions" edit mode: automatically detected when Claude is launched with `--dangerously-skip-permissions`; auto-allows all file edits without showing a diff dialog
- Fixed PreToolUse and Stop hook HTTP 502 errors (regression in claude-code 2.1.118)
- Fixed toolbar button focusing an existing session tab instead of opening a new one when all open tabs have active sessions
diff --git a/claude-launch-tests/test_skip_permission_mode.py b/claude-launch-tests/test_skip_permission_mode.py
new file mode 100644
index 00000000..f2233efa
--- /dev/null
+++ b/claude-launch-tests/test_skip_permission_mode.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""
+Discover the screen text for the "bypass permissions" (4th) edit mode.
+
+Claude Code cycles edit modes via Shift+Tab (ESC[Z):
+ default → plan → acceptEdits → bypassPermissions → default → ...
+
+This test:
+1. Launches Claude interactively.
+2. Waits for the READY state.
+3. Sends Shift+Tab up to 5 times, printing bottom 3 lines after each press.
+4. Exits after all presses so you can see every status-line variant.
+
+Usage:
+ cd claude-launch-tests && python3 test_skip_permission_mode.py
+
+Expected output: bottom status lines for each mode, including the
+4th "bypass permissions" mode text — use it to update ScreenContentDetector.
+"""
+
+import pexpect
+import time
+import sys
+
+CLAUDE = '/usr/local/bin/claude'
+CWD = '/tmp'
+SHIFT_TAB = '\x1b[Z'
+
+
+def bottom_lines(text: str, n: int = 5) -> list[str]:
+ """Return the last n non-blank lines from terminal output."""
+ lines = text.splitlines()
+ non_blank = [l.rstrip() for l in lines if l.strip()]
+ return non_blank[-n:] if len(non_blank) >= n else non_blank
+
+
+def main():
+ print("Launching Claude to discover bypass-permissions mode screen text...", flush=True)
+ child = pexpect.spawn(
+ CLAUDE, [],
+ cwd=CWD, encoding='utf-8', timeout=40,
+ logfile=open('/tmp/test_skip_perm_mode.log', 'w')
+ )
+
+ try:
+ # Wait for READY: input cursor ❯ or shift+tab text
+ print("Waiting for Claude READY state...", flush=True)
+ idx = child.expect([r'shift\+tab', r'❯', pexpect.TIMEOUT], timeout=30)
+ if idx == 2:
+ print("FAIL: Claude did not reach READY state", flush=True)
+ print("Last output:", repr(child.before[-500:]), flush=True)
+ sys.exit(1)
+
+ time.sleep(0.8)
+ # Read remaining buffered output
+ child.expect([pexpect.TIMEOUT], timeout=1.0)
+ buf = child.before
+ print("\n=== INITIAL STATE ===", flush=True)
+ for line in bottom_lines(buf):
+ print(" " + repr(line), flush=True)
+
+ # Cycle through modes via Shift+Tab
+ for i in range(1, 6):
+ print(f"\n=== Shift+Tab #{i} ===", flush=True)
+ child.send(SHIFT_TAB)
+ time.sleep(0.5)
+
+ # Read whatever came out
+ child.expect([pexpect.TIMEOUT], timeout=0.8)
+ buf = child.before
+ lines = bottom_lines(buf)
+ if lines:
+ for line in lines:
+ print(" " + repr(line), flush=True)
+ else:
+ print(" (no output)", flush=True)
+
+ print("\nDone. Check lines above for the bypass-permissions status line text.", flush=True)
+
+ except pexpect.EOF:
+ print("EOF — Claude exited unexpectedly", flush=True)
+ except Exception as e:
+ print(f"Error: {e}", flush=True)
+ finally:
+ child.close(force=True)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/claude-launch-tests/test_skip_permission_screen.py b/claude-launch-tests/test_skip_permission_screen.py
new file mode 100644
index 00000000..021b555a
--- /dev/null
+++ b/claude-launch-tests/test_skip_permission_screen.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""
+Capture the screen text when Claude is launched with --dangerously-skip-permissions.
+Prints the bottom status lines so we know what to detect.
+"""
+
+import pexpect
+import time
+
+CLAUDE = '/usr/local/bin/claude'
+CWD = '/tmp'
+
+
+def bottom_lines(text: str, n: int = 5) -> list:
+ lines = text.splitlines()
+ non_blank = [l.rstrip() for l in lines if l.strip()]
+ return non_blank[-n:] if len(non_blank) >= n else non_blank
+
+
+child = pexpect.spawn(
+ CLAUDE, ['--dangerously-skip-permissions'],
+ cwd=CWD, encoding='utf-8', timeout=30,
+ logfile=open('/tmp/test_skip_screen.log', 'w')
+)
+
+try:
+ idx = child.expect([r'shift\+tab', r'❯', r'bypass', r'skip', pexpect.TIMEOUT], timeout=20)
+ print(f"Matched index: {idx}", flush=True)
+ time.sleep(1.0)
+ child.expect([pexpect.TIMEOUT], timeout=1.0)
+ buf = child.before
+ print("=== Bottom lines after ready ===")
+ for line in bottom_lines(buf, 8):
+ print(" " + repr(line))
+finally:
+ child.close(force=True)
diff --git a/docs/to-do.md b/docs/to-do.md
new file mode 100644
index 00000000..3f9d1648
--- /dev/null
+++ b/docs/to-do.md
@@ -0,0 +1,39 @@
+# [ ] Bug 1
+
+Choice menu не распознаёт 4 как вариант для теустового ввода
+
+```
+╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+
+ Claude has written up a plan and is ready to execute. Would you like to proceed?
+
+ ❯ 1. Yes, auto-accept edits
+ 2. Yes, manually approve edits
+ 3. No, refine with Ultraplan on Claude Code on the web
+ 4. Tell Claude what to change
+ shift+tab to approve with this feedback
+
+ ctrl-g to edit in nano · ~/.claude/plans/https-github-com-nbclaudecodegui-netbean-serialized-kazoo.md
+```
+
+# [x] Bug 2
+
+```
+● Ran 1 stop hook
+ ⎿ http://localhost:28991/stop
+ ⎿ Stop hook error: HTTP 502 from http://localhost:28991/stop
+```
+
+# [ ] Feature 3
+
+Добавить возможность навигации стрелками в ChoiceMenuPanel:
+- Right, Left
+ - между Yes и No
+- Right - из checkbox в текстовое поле ввода, соответствующее этому чекбоксу, если оно есть
+- Up, Down
+ - С Yes, No - на последний/первый radiobox
+ - Из текстового поля ввода - на предыдущий/последующий пункт
+
+# [ ] Bug 4
+
+Не поддерживает skip permition mode в комбо.
diff --git a/pom.xml b/pom.xml
index c7d51f4a..7f184932 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbclaudecodegui
netbeans-claude-code-gui
- 0.21.58-SNAPSHOT
+ 0.21.59-SNAPSHOT
nbm
Netbeans 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/nbclaudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java
index b434df5e..365bee5e 100644
--- a/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java
+++ b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java
@@ -5,6 +5,7 @@
import com.pty4j.PtyProcess;
import io.github.nbclaudecodegui.model.ChoiceMenuModel;
import io.github.nbclaudecodegui.model.ClaudeSessionModel;
+import io.github.nbclaudecodegui.model.EditMode;
import io.github.nbclaudecodegui.model.SavedSession;
import io.github.nbclaudecodegui.model.SessionLifecycle;
import io.github.nbclaudecodegui.model.SessionMode;
@@ -242,7 +243,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr
widget.start();
// Ensure the initial edit mode is registered so the hook can read it
- String initialMode = model.getEditMode() != null ? model.getEditMode() : "default";
+ EditMode initialMode = model.getEditMode() != null ? model.getEditMode() : EditMode.DEFAULT;
model.setEditMode(initialMode);
modelDiscoveryAttempts = 0;
@@ -640,26 +641,26 @@ public void sendShiftTab() {
*
If the selected mode differs from the current model state, updates the
* model and triggers a Shift+Tab sequence to switch Claude's built-in mode.
*
- * @param newMode the newly selected mode string (one of {@code "plan"},
- * {@code "default"}, or {@code "acceptEdits"})
+ * @param newMode the newly selected mode
*/
- public void onEditModeComboChanged(String newMode) {
+ public void onEditModeComboChanged(EditMode newMode) {
if (newMode == null) return;
- if (newMode.equals(model.getEditMode())) return;
+ if (newMode == model.getEditMode()) return;
model.setEditMode(newMode);
sendShiftTabsUntilMode(newMode);
}
/**
* Sends Shift+Tab (ESC[Z) presses until the terminal screen shows
- * {@code targetMode}, or until 3 attempts are exhausted.
+ * {@code targetMode}, or until 5 attempts are exhausted.
+ * 5 attempts cover the full 4-mode cycle plus one extra in case of lag.
*
*
Runs on a daemon thread; sets lifecycle to WORKING at start and
* READY on completion.
*
* @param targetMode the edit mode to reach
*/
- private void sendShiftTabsUntilMode(String targetMode) {
+ private void sendShiftTabsUntilMode(EditMode targetMode) {
if (connector == null) return;
model.setLifecycle(SessionLifecycle.WORKING);
modeSwitchInProgress = true;
@@ -667,25 +668,25 @@ private void sendShiftTabsUntilMode(String targetMode) {
try {
LOG.info("sendShiftTabsUntilMode: target=" + targetMode
+ " currentModel=" + model.getEditMode());
- for (int attempt = 0; attempt < 3; attempt++) {
+ for (int attempt = 0; attempt < 5; attempt++) {
connector.write(new byte[]{0x1b, '[', 'Z'});
Thread.sleep(200);
- Optional detected =
+ Optional detected =
screenContentDetector.detectEditMode(screenLines.get());
LOG.fine("sendShiftTabsUntilMode: attempt=" + attempt
- + " detected=" + detected.orElse("(empty)"));
- if (detected.isPresent() && detected.get().equals(targetMode)) {
+ + " 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 ("default".equals(targetMode) && !detected.isPresent()) {
+ // 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;
}
}
LOG.warning("sendShiftTabsUntilMode: did not reach " + targetMode
- + " after 3 attempts");
+ + " after 5 attempts");
} catch (IOException | InterruptedException ex) {
LOG.warning("sendShiftTabsUntilMode failed: " + ex.getMessage());
} finally {
@@ -988,17 +989,18 @@ void pollScreenState() {
// Continuously sync CC screen mode → model (skip during switches and discovery)
if (modelComboPopulated && !modeSwitchInProgress && !modelDiscoveryInProgress) {
- Optional detected = screenContentDetector.detectEditMode(lines);
+ Optional detected = screenContentDetector.detectEditMode(lines);
+ LOG.fine("[pollScreenState] editMode sync: detected=" + detected.orElse(null) + " current=" + model.getEditMode());
if (detected.isPresent()) {
- String mode = detected.get();
- if (!mode.equals(model.getEditMode())) {
+ EditMode mode = detected.get();
+ if (mode != model.getEditMode()) {
model.setEditMode(mode);
}
} else if (model.getLifecycle() != SessionLifecycle.WORKING) {
// Unknown mode outside WORKING (screen transitioning or idle with no indicator)
// → treat as Ask/default. During WORKING: preserve current registry value.
- if (!"default".equals(model.getEditMode())) {
- model.setEditMode("default");
+ if (model.getEditMode() != EditMode.DEFAULT) {
+ model.setEditMode(EditMode.DEFAULT);
}
}
}
@@ -1072,7 +1074,13 @@ private void handleHang(int timeoutSec) {
}
private void detectAndApplyInitialEditMode(List lines) {
- screenContentDetector.detectEditMode(lines).ifPresent(m -> model.setEditMode(m));
+ Optional detected = screenContentDetector.detectEditMode(lines);
+ List bottom = new java.util.ArrayList<>();
+ for (int i = lines.size() - 1; i >= 0 && bottom.size() < 3; i--) {
+ if (!lines.get(i).isBlank()) bottom.add(lines.get(i));
+ }
+ LOG.fine("[detectAndApplyInitialEditMode] detected=" + detected.orElse(null) + " bottom3=" + bottom);
+ detected.ifPresent(m -> model.setEditMode(m));
}
/**
diff --git a/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java
index da5035f5..683e1afe 100644
--- a/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java
+++ b/src/main/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandler.java
@@ -3,6 +3,7 @@
package io.github.nbclaudecodegui.mcp;
import com.fasterxml.jackson.databind.JsonNode;
+import io.github.nbclaudecodegui.model.EditMode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -501,15 +502,17 @@ public CompletableFuture handlePreToolUse(String hookJson) {
// cwd from hook JSON == confirmedDirectory of the session that launched Claude
String cwd = hook.has("cwd") ? hook.get("cwd").asText() : null;
- String editMode = getEditModeForCwd(cwd);
+ EditMode editMode = getEditModeForCwd(cwd);
LOGGER.info("handlePreToolUse: editMode=" + editMode + " cwd=" + cwd);
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 ("acceptEdits".equals(editMode)
- && io.github.nbclaudecodegui.ui.FileDiffOpener.isFileUnderDirectory(filePath, cwd)) {
- LOGGER.info("acceptEdits mode — file inside project, auto-allowing: " + filePath);
+ if (editMode == EditMode.BYPASS_PERMISSIONS
+ || (editMode == EditMode.ACCEPT_EDITS
+ && io.github.nbclaudecodegui.ui.FileDiffOpener.isFileUnderDirectory(filePath, cwd))) {
+ LOGGER.info(editMode.key() + " mode — auto-allowing: " + filePath);
return CompletableFuture.completedFuture(hookAllowJson());
}
// plan / ask / acceptEdits-outside: fall through to show diff dialog
@@ -681,16 +684,16 @@ private static java.util.Optional findSessionByCwd(String cwd)
}
/**
- * Returns the edit mode of the session whose working directory is {@code cwd},
- * or {@code "default"} if the session cannot be found.
+ * Returns the {@link EditMode} of the session whose working directory is {@code cwd},
+ * or {@link EditMode#DEFAULT} if the session cannot be found.
*
* Safe to call from any thread — uses the thread-safe registry in
* {@link io.github.nbclaudecodegui.model.ClaudeSessionModel#EDIT_MODE_REGISTRY}.
*/
- private static String getEditModeForCwd(String cwd) {
- if (cwd == null) return "default";
- String mode = io.github.nbclaudecodegui.model.ClaudeSessionModel.EDIT_MODE_REGISTRY.get(cwd);
- return mode != null ? mode : "default";
+ private static EditMode getEditModeForCwd(String cwd) {
+ if (cwd == null) return EditMode.DEFAULT;
+ EditMode mode = io.github.nbclaudecodegui.model.ClaudeSessionModel.EDIT_MODE_REGISTRY.get(cwd);
+ return mode != null ? mode : EditMode.DEFAULT;
}
/**
diff --git a/src/main/java/io/github/nbclaudecodegui/model/ClaudeSessionModel.java b/src/main/java/io/github/nbclaudecodegui/model/ClaudeSessionModel.java
index f6f7e2ab..74286598 100644
--- a/src/main/java/io/github/nbclaudecodegui/model/ClaudeSessionModel.java
+++ b/src/main/java/io/github/nbclaudecodegui/model/ClaudeSessionModel.java
@@ -33,15 +33,16 @@ public final class ClaudeSessionModel {
// -------------------------------------------------------------------------
/**
- * Cross-session, thread-safe registry of {@code editMode} keyed by absolute
+ * Cross-session, thread-safe registry of {@link EditMode} keyed by absolute
* working-directory path.
*
*
Updated whenever {@link #setEditMode} is called; cleared by
* {@link #clearEditModeRegistry} when a session stops.
* Read by {@code NetBeansMCPHandler.getEditModeForCwd()} on servlet threads
- * to decide whether to auto-allow file edits in "acceptEdits" mode.
+ * to decide whether to auto-allow file edits in {@link EditMode#ACCEPT_EDITS}
+ * or {@link EditMode#BYPASS_PERMISSIONS} mode.
*/
- public static final ConcurrentHashMap EDIT_MODE_REGISTRY =
+ public static final ConcurrentHashMap EDIT_MODE_REGISTRY =
new ConcurrentHashMap<>();
// -------------------------------------------------------------------------
@@ -65,12 +66,11 @@ public interface ClaudeSessionModelListener {
default void onLifecycleChanged(SessionLifecycle state) {}
/**
- * Called when the edit mode changes (e.g. {@code "default"}, {@code "plan"},
- * {@code "acceptEdits"}).
+ * Called when the edit mode changes.
*
- * @param mode the new mode string, or {@code null} if cleared
+ * @param mode the new mode, or {@code null} if cleared
*/
- default void onEditModeChanged(String mode) {}
+ default void onEditModeChanged(EditMode mode) {}
/**
* Called when the list of available models or the selected index changes.
@@ -113,10 +113,9 @@ default void onPlanNameChanged(String planName) {}
private volatile File workingDirectory;
/**
- * Current edit mode: {@code "default"}, {@code "plan"}, or {@code "acceptEdits"}.
- * Also written to {@link #EDIT_MODE_REGISTRY} when set.
+ * Current edit mode. Also written to {@link #EDIT_MODE_REGISTRY} when set.
*/
- private volatile String editMode;
+ private volatile EditMode editMode;
/** Name of the selected profile; may be {@code null} for Default. */
private volatile String selectedProfileName;
@@ -187,11 +186,11 @@ public void removeListener(ClaudeSessionModelListener l) {
public File getWorkingDirectory() { return workingDirectory; }
/**
- * Returns the current edit mode string.
+ * Returns the current edit mode.
*
* @return edit mode, or {@code null} if not yet set
*/
- public String getEditMode() { return editMode; }
+ public EditMode getEditMode() { return editMode; }
/**
* Returns the selected profile name.
@@ -260,9 +259,9 @@ public void setLifecycle(SessionLifecycle s) {
*
* Safe to call from any thread.
*
- * @param mode the new edit mode string, or {@code null} to clear
+ * @param mode the new edit mode, or {@code null} to clear
*/
- public void setEditMode(String mode) {
+ public void setEditMode(EditMode mode) {
editMode = mode;
if (workingDirectory != null) {
if (mode != null) {
diff --git a/src/main/java/io/github/nbclaudecodegui/model/EditMode.java b/src/main/java/io/github/nbclaudecodegui/model/EditMode.java
new file mode 100644
index 00000000..996e42e0
--- /dev/null
+++ b/src/main/java/io/github/nbclaudecodegui/model/EditMode.java
@@ -0,0 +1,84 @@
+package io.github.nbclaudecodegui.model;
+
+import java.util.Optional;
+
+/**
+ * Claude Code interactive edit modes, cyclable via Shift+Tab in the TUI.
+ *
+ *
Each constant carries a {@link #key()} string that is used as the canonical
+ * identifier in {@link ClaudeSessionModel#EDIT_MODE_REGISTRY}, in serialized
+ * settings, and in logging.
+ *
+ *
Screen-text mapping (used by {@code ScreenContentDetector.detectEditMode}):
+ *
+ * - {@link #PLAN} — bottom line contains {@code "plan mode"} or {@code "plan-mode"}
+ * - {@link #ACCEPT_EDITS} — bottom line contains {@code "accept edits"}
+ * - {@link #BYPASS_PERMISSIONS} — bottom line contains {@code "bypass permissions"};
+ * appears when Claude was launched with {@code --dangerously-skip-permissions}
+ * - {@link #DEFAULT} — bottom line starts with {@code " esc to interrupt"}
+ * (two leading spaces)
+ *
+ */
+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("plan"),
+
+ /**
+ * Accept-edits — auto-allows file changes inside the session's working
+ * directory; shows a diff dialog for files outside.
+ */
+ ACCEPT_EDITS("acceptEdits"),
+
+ /**
+ * Bypass-permissions — auto-allows all file changes regardless of location.
+ * Active when Claude was launched with {@code --dangerously-skip-permissions}.
+ * Detected on screen as {@code ⏵⏵ bypass permissions on (shift+tab to cycle)}.
+ */
+ BYPASS_PERMISSIONS("bypassPermissions");
+
+ // -------------------------------------------------------------------------
+
+ private final String key;
+
+ EditMode(String key) {
+ this.key = key;
+ }
+
+ /**
+ * Returns the canonical string key for this mode (e.g. {@code "acceptEdits"}).
+ *
+ * @return canonical key
+ */
+ public String key() {
+ return key;
+ }
+
+ /**
+ * Finds the {@code EditMode} whose {@link #key()} equals {@code key}.
+ *
+ * @param key the key to look up
+ * @return matching mode, or {@link Optional#empty()} if not found
+ */
+ public static Optional fromKey(String key) {
+ if (key == null) return Optional.empty();
+ for (EditMode m : values()) {
+ if (m.key.equals(key)) return Optional.of(m);
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Finds the {@code EditMode} whose {@link #key()} equals {@code key},
+ * falling back to {@link #DEFAULT} if not found.
+ *
+ * @param key the key to look up; {@code null} returns {@link #DEFAULT}
+ * @return matching mode, or {@link #DEFAULT}
+ */
+ public static EditMode fromKeyOrDefault(String key) {
+ return fromKey(key).orElse(DEFAULT);
+ }
+}
diff --git a/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java b/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java
index 7845ca8f..32f17ba7 100644
--- a/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java
+++ b/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java
@@ -1,6 +1,7 @@
package io.github.nbclaudecodegui.process;
import io.github.nbclaudecodegui.model.ChoiceMenuModel;
+import io.github.nbclaudecodegui.model.EditMode;
import io.github.nbclaudecodegui.settings.ClaudeCodePreferences;
import java.util.ArrayList;
@@ -438,30 +439,43 @@ private static boolean isBoxDrawingLine(String trimmed) {
/**
* Detect the Claude CLI edit mode from the rendered screen footer.
*
- * Returns {@code "plan"} if "plan mode" is visible in the bottom 3 lines,
- * or {@code "default"} otherwise. {@code "acceptEdits"} is our own overlay and
- * cannot be detected from the screen; it is never returned here.
+ *
Possible return values:
+ *
+ * - {@link EditMode#PLAN} — bottom line contains {@code "plan mode"} or
+ * {@code "plan-mode"}
+ * - {@link EditMode#ACCEPT_EDITS} — bottom line contains
+ * {@code "accept edits"}
+ * - {@link EditMode#BYPASS_PERMISSIONS} — bottom line contains
+ * {@code "bypass permissions"}; present when Claude was launched with
+ * {@code --dangerously-skip-permissions}
+ * - {@link EditMode#DEFAULT} — bottom line starts with
+ * {@code " esc to interrupt"} (two leading spaces)
+ * - {@link Optional#empty()} — screen is blank or transitioning
+ *
*
* @param lines rendered screen lines
- * @return {@code Optional} of {@code "plan"} or {@code "default"}
+ * @return detected {@link EditMode}, or empty if unknown
*/
- public Optional detectEditMode(List lines) {
+ public Optional detectEditMode(List lines) {
if (lines == null || lines.isEmpty()) return Optional.empty();
List bottom = bottomNonBlankLines(lines, 3);
if (bottom.isEmpty()) return Optional.empty();
for (String line : bottom) {
String lower = line.toLowerCase();
if (lower.contains("plan mode") || lower.contains("plan-mode")) {
- return Optional.of("plan");
+ return Optional.of(EditMode.PLAN);
}
if (lower.contains("accept edits")) {
- return Optional.of("acceptEdits");
+ return Optional.of(EditMode.ACCEPT_EDITS);
+ }
+ if (lower.contains("bypass permissions")) {
+ return Optional.of(EditMode.BYPASS_PERMISSIONS);
}
}
// " 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.
if (bottom.get(0).startsWith(" esc to interrupt")) {
- return Optional.of("default");
+ return Optional.of(EditMode.DEFAULT);
}
return Optional.empty();
}
diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
index e4fc3c3f..67d71d30 100644
--- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
+++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
@@ -6,6 +6,7 @@
import io.github.nbclaudecodegui.ui.common.BasicTextContextMenu;
import io.github.nbclaudecodegui.model.ChoiceMenuModel;
import io.github.nbclaudecodegui.model.ClaudeSessionModel;
+import io.github.nbclaudecodegui.model.EditMode;
import io.github.nbclaudecodegui.model.SavedSession;
import io.github.nbclaudecodegui.model.SessionLifecycle;
import io.github.nbclaudecodegui.model.SessionMode;
@@ -104,8 +105,13 @@ public class ClaudeSessionTab extends TopComponent
// Edit mode constants
// -------------------------------------------------------------------------
- static final String[] EDIT_MODE_LABELS = {"Plan Mode", "Ask on Edit", "Accept on Edit"};
- static final String[] EDIT_MODE_VALUES = {"plan", "default", "acceptEdits"};
+ /** 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};
+
+ /** Label / value for the 4th entry added dynamically when bypass-permissions is detected. */
+ private static final String BYPASS_LABEL = "Bypass Permissions";
+ private static final EditMode BYPASS_VALUE = EditMode.BYPASS_PERMISSIONS;
// -------------------------------------------------------------------------
// South card constants
@@ -190,6 +196,11 @@ public class ClaudeSessionTab extends TopComponent
/** Cached lifecycle so {@link #onModelListChanged} can correctly enable the combo. */
private volatile SessionLifecycle currentLifecycle;
+ /** True once BYPASS_PERMISSIONS mode was detected; keeps the 4th combo entry visible. */
+ private boolean bypassPermissionsAvailable = false;
+ /** Guards programmatic combo updates to prevent feedback through the action listener. */
+ private boolean updatingCombo = false;
+
// -------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------
@@ -466,6 +477,15 @@ public void onLifecycleChanged(SessionLifecycle s) {
case READY -> "Ready";
case WORKING -> "Working";
});
+ if (s == SessionLifecycle.STARTING && bypassPermissionsAvailable) {
+ updatingCombo = true;
+ try {
+ editModeCombo.removeItem(BYPASS_LABEL);
+ } finally {
+ updatingCombo = false;
+ }
+ bypassPermissionsAvailable = false;
+ }
boolean ready = s == SessionLifecycle.READY;
modelCombo.setEnabled(ready && modelCombo.getItemCount() > 0);
promptPanel.setReadyState(ready);
@@ -498,18 +518,40 @@ public void onChoiceMenuChanged(ChoiceMenuModel menu) {
}
}
- /** Syncs the edit-mode combo without re-triggering the action listener. */
+ /**
+ * Syncs the edit-mode combo without re-triggering the action listener.
+ *
+ * If {@code mode} is {@link EditMode#BYPASS_PERMISSIONS} and the combo does
+ * not yet have a 4th entry, it is added dynamically. When any other mode is
+ * detected the 4th entry is removed — bypass-permissions is only available
+ * while Claude itself is in that state.
+ */
@Override
- public void onEditModeChanged(String mode) {
+ public void onEditModeChanged(EditMode mode) {
+ LOG.fine("[onEditModeChanged] mode=" + mode + " comboItems=" + editModeCombo.getItemCount());
if (mode == null) return;
- int idx = editModeIndexOf(mode);
- if (idx >= 0 && editModeCombo.getSelectedIndex() != idx) {
- editModeCombo.removeActionListener(editModeCombo.getActionListeners()[0]);
- editModeCombo.setSelectedIndex(idx);
- editModeCombo.addActionListener(ae -> onEditModeComboChanged());
+ if (mode == BYPASS_VALUE) bypassPermissionsAvailable = true;
+ updatingCombo = true;
+ try {
+ if (bypassPermissionsAvailable && !hasBypassItem()) {
+ editModeCombo.addItem(BYPASS_LABEL);
+ }
+ int idx = editModeIndexOf(mode);
+ if (idx >= 0 && editModeCombo.getSelectedIndex() != idx) {
+ editModeCombo.setSelectedIndex(idx);
+ }
+ } finally {
+ updatingCombo = false;
}
}
+ private boolean hasBypassItem() {
+ for (int i = 0; i < editModeCombo.getItemCount(); i++) {
+ if (BYPASS_LABEL.equals(editModeCombo.getItemAt(i))) return true;
+ }
+ return false;
+ }
+
/** Repopulates the model combo. */
@Override
public void onModelListChanged(List models, int selectedIdx) {
@@ -552,17 +594,20 @@ public File getWorkingDirectory() {
}
/**
- * Programmatically sets the session edit mode (e.g. {@code "acceptEdits"}).
+ * Programmatically sets the session edit mode.
* No-op if the value is not a known mode or is already selected.
*
- * @param value the edit mode value (one of {@code EDIT_MODE_VALUES})
+ * @param value the edit mode (one of {@link EditMode} values)
*/
- public void setEditMode(String value) {
+ public void setEditMode(EditMode value) {
int idx = editModeIndexOf(value);
if (idx >= 0 && editModeCombo.getSelectedIndex() != idx) {
- editModeCombo.removeActionListener(editModeCombo.getActionListeners()[0]);
- editModeCombo.setSelectedIndex(idx);
- editModeCombo.addActionListener(ae -> onEditModeComboChanged());
+ updatingCombo = true;
+ try {
+ editModeCombo.setSelectedIndex(idx);
+ } finally {
+ updatingCombo = false;
+ }
controller.onEditModeComboChanged(value);
}
}
@@ -626,11 +671,11 @@ public void triggerPromptScan() {
}
/**
- * Returns the current edit mode string.
+ * Returns the current edit mode, or {@code null} if not yet set.
*
- * @return edit mode string, or {@code null} if not set
+ * @return edit mode or {@code null}
*/
- public String getEditMode() {
+ public EditMode getEditMode() {
return model.getEditMode();
}
@@ -1233,9 +1278,10 @@ public void hideEmbeddedDiff() {
// -------------------------------------------------------------------------
private void onEditModeComboChanged() {
+ if (updatingCombo) return;
int idx = editModeCombo.getSelectedIndex();
- if (idx < 0 || idx >= EDIT_MODE_VALUES.length) return;
- controller.onEditModeComboChanged(EDIT_MODE_VALUES[idx]);
+ EditMode mode = editModeAt(idx);
+ if (mode != null) controller.onEditModeComboChanged(mode);
}
private void onModelComboChanged() {
@@ -1244,13 +1290,29 @@ private void onModelComboChanged() {
controller.switchModel(idx);
}
- private int editModeIndexOf(String value) {
+ /** Returns the {@link EditMode} for combo index {@code idx}, or {@code null} if out of range. */
+ private EditMode editModeAt(int idx) {
+ if (idx < 0) return null;
+ if (idx < EDIT_MODE_VALUES.length) return EDIT_MODE_VALUES[idx];
+ if (idx == EDIT_MODE_VALUES.length && editModeCombo.getItemCount() > EDIT_MODE_VALUES.length) {
+ return BYPASS_VALUE;
+ }
+ return null;
+ }
+
+ private int editModeIndexOf(EditMode value) {
+ if (value == BYPASS_VALUE) {
+ return editModeCombo.getItemCount() > EDIT_MODE_VALUES.length ? EDIT_MODE_VALUES.length : -1;
+ }
for (int i = 0; i < EDIT_MODE_VALUES.length; i++) {
- if (EDIT_MODE_VALUES[i].equals(value)) return i;
+ if (EDIT_MODE_VALUES[i] == value) return i;
}
return -1;
}
+ /** Package-private for testing only. */
+ int editModeItemCount() { return editModeCombo.getItemCount(); }
+
private static JPanel makeSep() {
JPanel sep = new JPanel();
sep.setLayout(new BoxLayout(sep, BoxLayout.X_AXIS));
diff --git a/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java b/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
index e484a98f..0b3d483b 100644
--- a/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
+++ b/src/main/java/io/github/nbclaudecodegui/ui/FileDiffOpener.java
@@ -1,5 +1,6 @@
package io.github.nbclaudecodegui.ui;
+import io.github.nbclaudecodegui.model.EditMode;
import io.github.nbclaudecodegui.settings.ClaudeCodePreferences;
import java.io.File;
import java.io.IOException;
@@ -179,7 +180,7 @@ public static void open(
activateSessionForFile(filePath);
},
sessionTab == null ? null : () -> {
- sessionTab.setEditMode("acceptEdits");
+ sessionTab.setEditMode(EditMode.ACCEPT_EDITS);
decided.set(true);
onAccept.run();
closeDiffRef[0].run();
diff --git a/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java
index 00f65dc9..ac58407b 100644
--- a/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java
@@ -1,6 +1,7 @@
package io.github.nbclaudecodegui.controller;
import io.github.nbclaudecodegui.model.ClaudeSessionModel;
+import io.github.nbclaudecodegui.model.EditMode;
import io.github.nbclaudecodegui.model.SessionLifecycle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -67,23 +68,23 @@ void cancelPromptWithNoConnectorDoesNotCrash() {
void onEditModeComboChangedUpdatesModel() {
// Set up directory so registry write works
model.setWorkingDirectory(new java.io.File("/tmp/test"));
- controller.onEditModeComboChanged("acceptEdits");
- assertEquals("acceptEdits", model.getEditMode());
+ controller.onEditModeComboChanged(EditMode.ACCEPT_EDITS);
+ assertEquals(EditMode.ACCEPT_EDITS, model.getEditMode());
}
@Test
void onEditModeComboChangedNoOpWhenSameMode() {
model.setWorkingDirectory(new java.io.File("/tmp/test"));
- model.setEditMode("default");
+ model.setEditMode(EditMode.DEFAULT);
- AtomicReference captured = new AtomicReference<>(null);
+ AtomicReference captured = new AtomicReference<>(null);
model.addListener(new NoOpListener() {
- @Override public void onEditModeChanged(String mode) {
+ @Override public void onEditModeChanged(io.github.nbclaudecodegui.model.EditMode mode) {
captured.set(mode);
}
});
- controller.onEditModeComboChanged("default"); // same as current
+ controller.onEditModeComboChanged(EditMode.DEFAULT); // same as current
// Listener must NOT have been called (no change)
assertNull(captured.get(), "No notification expected when mode is unchanged");
}
@@ -140,7 +141,7 @@ void pollScreenStateDoesNotOverwriteAcceptEditsWhileWorking() throws Exception {
ClaudeSessionController c2 = new ClaudeSessionController(m2, () -> workingScreen);
m2.setWorkingDirectory(new java.io.File("/tmp/test2"));
- m2.setEditMode("acceptEdits");
+ m2.setEditMode(EditMode.ACCEPT_EDITS);
m2.setLifecycle(SessionLifecycle.WORKING);
// Simulate modelComboPopulated = true so the sync branch is reached
@@ -151,9 +152,9 @@ void pollScreenStateDoesNotOverwriteAcceptEditsWhileWorking() throws Exception {
c2.pollScreenState();
- assertEquals("acceptEdits", m2.getEditMode(),
+ assertEquals(EditMode.ACCEPT_EDITS, m2.getEditMode(),
"acceptEdits must not be overwritten by screen poll while WORKING");
- assertEquals("acceptEdits",
+ assertEquals(EditMode.ACCEPT_EDITS,
io.github.nbclaudecodegui.model.ClaudeSessionModel.EDIT_MODE_REGISTRY
.get("/tmp/test2"),
"Registry must still hold acceptEdits");
@@ -176,7 +177,7 @@ void pollScreenStateDetectsPlanModeChangeWhileWorking() throws Exception {
ClaudeSessionController c3 = new ClaudeSessionController(m3, () -> planScreen);
m3.setWorkingDirectory(new java.io.File("/tmp/test3"));
- m3.setEditMode("default");
+ m3.setEditMode(EditMode.DEFAULT);
m3.setLifecycle(SessionLifecycle.WORKING);
java.lang.reflect.Field f =
@@ -186,7 +187,7 @@ void pollScreenStateDetectsPlanModeChangeWhileWorking() throws Exception {
c3.pollScreenState();
- assertEquals("plan", m3.getEditMode(),
+ assertEquals(EditMode.PLAN, m3.getEditMode(),
"plan mode must be detected even during WORKING when text is visible");
}
@@ -544,7 +545,7 @@ void detectEditMode_emptyResultMeansDefaultModeOnIdleScreen() {
"\u276F Ask anything...",
"────────────────────────────────────────"
);
- java.util.Optional result = detector.detectEditMode(idleDefaultScreen);
+ java.util.Optional result = detector.detectEditMode(idleDefaultScreen);
// detectEditMode returns empty for idle default screen (no "plan mode" / "accept edits" /
// " esc to interrupt" present). sendShiftTabsUntilMode must treat this as "default".
assertTrue(result.isEmpty(),
@@ -624,7 +625,7 @@ void hangNotDetectedWhenWithinTimeout() {
private static class NoOpListener
implements ClaudeSessionModel.ClaudeSessionModelListener {
@Override public void onLifecycleChanged(SessionLifecycle state) {}
- @Override public void onEditModeChanged(String mode) {}
+ @Override public void onEditModeChanged(io.github.nbclaudecodegui.model.EditMode mode) {}
@Override public void onModelListChanged(List models, int selectedIdx) {}
@Override public void onChoiceMenuChanged(io.github.nbclaudecodegui.model.ChoiceMenuModel menu) {}
@Override public void onWorkingDirectoryChanged(java.io.File dir) {}
diff --git a/src/test/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandlerHookTest.java b/src/test/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandlerHookTest.java
new file mode 100644
index 00000000..f57bde91
--- /dev/null
+++ b/src/test/java/io/github/nbclaudecodegui/mcp/NetBeansMCPHandlerHookTest.java
@@ -0,0 +1,145 @@
+package io.github.nbclaudecodegui.mcp;
+
+import io.github.nbclaudecodegui.model.ClaudeSessionModel;
+import io.github.nbclaudecodegui.model.EditMode;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link NetBeansMCPHandler#handlePreToolUse} auto-allow logic.
+ *
+ * Verifies the fast-path decisions made before opening a diff dialog:
+ *
+ * - {@link EditMode#BYPASS_PERMISSIONS} — auto-allows everything
+ * - {@link EditMode#ACCEPT_EDITS} — auto-allows only files inside {@code cwd}
+ * - Other modes — fall through (in test: future never completes via dialog)
+ *
+ *
+ * The handler under test is constructed via {@link #handler}, which wraps the
+ * real {@link NetBeansMCPHandler} but throws if a diff dialog would be opened
+ * (i.e. we never expect the slow path in these tests).
+ */
+class NetBeansMCPHandlerHookTest {
+
+ private static final String CWD = "/tmp/test-project";
+ private static final String FILE_INSIDE = "/tmp/test-project/src/Foo.java";
+ private static final String FILE_OUTSIDE = "/tmp/other-project/Bar.java";
+
+ private NetBeansMCPHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ ClaudeSessionModel.EDIT_MODE_REGISTRY.clear();
+ handler = new NetBeansMCPHandler();
+ }
+
+ @AfterEach
+ void tearDown() {
+ ClaudeSessionModel.EDIT_MODE_REGISTRY.clear();
+ }
+
+ // -------------------------------------------------------------------------
+ // bypassPermissions — auto-allow regardless of file location
+ // -------------------------------------------------------------------------
+
+ @Test
+ void bypassPermissionsAutoAllowsFileInsideProject() throws Exception {
+ ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.BYPASS_PERMISSIONS);
+
+ CompletableFuture future = handler.handlePreToolUse(editJson(FILE_INSIDE));
+
+ // Future must complete immediately (no dialog)
+ assertTrue(future.isDone(), "Future must be completed immediately in bypassPermissions mode");
+ String result = future.get(1, TimeUnit.SECONDS);
+ assertTrue(result.contains("\"allow\""), "Response must be allow, got: " + result);
+ }
+
+ @Test
+ void bypassPermissionsAutoAllowsFileOutsideProject() throws Exception {
+ ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.BYPASS_PERMISSIONS);
+
+ CompletableFuture future = handler.handlePreToolUse(editJson(FILE_OUTSIDE));
+
+ assertTrue(future.isDone(), "Future must be completed immediately in bypassPermissions mode");
+ String result = future.get(1, TimeUnit.SECONDS);
+ assertTrue(result.contains("\"allow\""),
+ "bypassPermissions must allow files outside project, got: " + result);
+ }
+
+ // -------------------------------------------------------------------------
+ // acceptEdits — auto-allow only inside cwd
+ // -------------------------------------------------------------------------
+
+ @Test
+ void acceptEditsAutoAllowsFileInsideProject() throws Exception {
+ ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.ACCEPT_EDITS);
+
+ CompletableFuture future = handler.handlePreToolUse(editJson(FILE_INSIDE));
+
+ assertTrue(future.isDone(), "Future must be completed immediately for file inside cwd");
+ String result = future.get(1, TimeUnit.SECONDS);
+ assertTrue(result.contains("\"allow\""), "Response must be allow, got: " + result);
+ }
+
+ @Test
+ void acceptEditsDoesNotAutoAllowFileOutsideProject() throws Exception {
+ ClaudeSessionModel.EDIT_MODE_REGISTRY.put(CWD, EditMode.ACCEPT_EDITS);
+
+ CompletableFuture future = handler.handlePreToolUse(editJson(FILE_OUTSIDE));
+
+ // In headless tests FileDiffOpener.open() cannot open a tab → exception caught →
+ // returns hookAskJson(). Either way the response must NOT be "allow".
+ assertTrue(future.isDone());
+ String result = future.get(1, TimeUnit.SECONDS);
+ assertFalse(result.contains("\"allow\""),
+ "acceptEdits must not auto-allow files outside project, got: " + result);
+ }
+
+ // -------------------------------------------------------------------------
+ // No registry entry → fall through to diff dialog
+ // -------------------------------------------------------------------------
+
+ @Test
+ void noRegistryEntryDoesNotAutoAllow() throws Exception {
+ // No entry for CWD — default mode: must not auto-allow
+ CompletableFuture future = handler.handlePreToolUse(editJson(FILE_INSIDE));
+
+ // In headless tests FileDiffOpener.open() cannot open a tab → exception caught →
+ // returns hookAskJson(). Either way the response must NOT be "allow".
+ assertTrue(future.isDone());
+ String result = future.get(1, TimeUnit.SECONDS);
+ assertFalse(result.contains("\"allow\""),
+ "Without a registry entry the handler must not auto-allow, got: " + result);
+ }
+
+ // -------------------------------------------------------------------------
+ // Non-file-edit tools are always auto-allowed
+ // -------------------------------------------------------------------------
+
+ @Test
+ void nonFileEditToolAutoAllowed() throws Exception {
+ String json = "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"},\"cwd\":\"" + CWD + "\"}";
+
+ CompletableFuture future = handler.handlePreToolUse(json);
+
+ assertTrue(future.isDone(), "Non-file-edit tool must be auto-allowed immediately");
+ assertTrue(future.get(1, TimeUnit.SECONDS).contains("\"allow\""));
+ }
+
+ // -------------------------------------------------------------------------
+ // helpers
+ // -------------------------------------------------------------------------
+
+ private static String editJson(String filePath) {
+ return "{\"tool_name\":\"Edit\","
+ + "\"tool_input\":{\"file_path\":\"" + filePath + "\","
+ + "\"old_string\":\"x\",\"new_string\":\"y\"},"
+ + "\"cwd\":\"" + CWD + "\"}";
+ }
+}
diff --git a/src/test/java/io/github/nbclaudecodegui/model/ClaudeSessionModelTest.java b/src/test/java/io/github/nbclaudecodegui/model/ClaudeSessionModelTest.java
index 53155c14..b6e3ed48 100644
--- a/src/test/java/io/github/nbclaudecodegui/model/ClaudeSessionModelTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/model/ClaudeSessionModelTest.java
@@ -74,41 +74,41 @@ void setLifecycleFiresListener() {
@Test
void setEditModeFiresListener() {
- AtomicReference captured = new AtomicReference<>();
+ AtomicReference captured = new AtomicReference<>();
model.addListener(new NoOpListener() {
- @Override public void onEditModeChanged(String mode) {
+ @Override public void onEditModeChanged(EditMode mode) {
captured.set(mode);
}
});
- model.setEditMode("acceptEdits");
+ model.setEditMode(EditMode.ACCEPT_EDITS);
- assertEquals("acceptEdits", model.getEditMode());
- assertEquals("acceptEdits", captured.get());
+ assertEquals(EditMode.ACCEPT_EDITS, model.getEditMode());
+ assertEquals(EditMode.ACCEPT_EDITS, captured.get());
}
@Test
void setEditModeWritesRegistryWhenDirectorySet() {
File dir = new File("/tmp/test-dir");
model.setWorkingDirectory(dir);
- model.setEditMode("plan");
+ model.setEditMode(EditMode.PLAN);
- assertEquals("plan", ClaudeSessionModel.EDIT_MODE_REGISTRY.get(dir.getAbsolutePath()));
+ assertEquals(EditMode.PLAN, ClaudeSessionModel.EDIT_MODE_REGISTRY.get(dir.getAbsolutePath()));
}
@Test
void setEditModeWithNullDirectoryDoesNotCrash() {
// directory is null → registry update skipped silently
assertNull(model.getWorkingDirectory());
- assertDoesNotThrow(() -> model.setEditMode("acceptEdits"));
- assertEquals("acceptEdits", model.getEditMode());
+ assertDoesNotThrow(() -> model.setEditMode(EditMode.ACCEPT_EDITS));
+ assertEquals(EditMode.ACCEPT_EDITS, model.getEditMode());
}
@Test
void setEditModeNullRemovesRegistryEntry() {
File dir = new File("/tmp/test-dir");
model.setWorkingDirectory(dir);
- model.setEditMode("plan");
+ model.setEditMode(EditMode.PLAN);
assertNotNull(ClaudeSessionModel.EDIT_MODE_REGISTRY.get(dir.getAbsolutePath()));
model.setEditMode(null);
@@ -256,7 +256,7 @@ void removedListenerReceivesNoMoreNotifications() {
void clearEditModeRegistryCleansEntry() {
File dir = new File("/tmp/test-project");
model.setWorkingDirectory(dir);
- model.setEditMode("acceptEdits");
+ model.setEditMode(EditMode.ACCEPT_EDITS);
assertNotNull(ClaudeSessionModel.EDIT_MODE_REGISTRY.get(dir.getAbsolutePath()));
model.clearEditModeRegistry();
@@ -276,7 +276,7 @@ void clearEditModeRegistryWithNullDirectoryDoesNotCrash() {
private static class NoOpListener
implements ClaudeSessionModel.ClaudeSessionModelListener {
@Override public void onLifecycleChanged(SessionLifecycle state) {}
- @Override public void onEditModeChanged(String mode) {}
+ @Override public void onEditModeChanged(io.github.nbclaudecodegui.model.EditMode mode) {}
@Override public void onModelListChanged(List models, int selectedIdx) {}
@Override public void onChoiceMenuChanged(ChoiceMenuModel menu) {}
@Override public void onWorkingDirectoryChanged(File dir) {}
diff --git a/src/test/java/io/github/nbclaudecodegui/model/EditModeTest.java b/src/test/java/io/github/nbclaudecodegui/model/EditModeTest.java
new file mode 100644
index 00000000..e80f8b1c
--- /dev/null
+++ b/src/test/java/io/github/nbclaudecodegui/model/EditModeTest.java
@@ -0,0 +1,46 @@
+package io.github.nbclaudecodegui.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for {@link EditMode}.
+ */
+class EditModeTest {
+
+ @Test
+ void keyRoundtrip() {
+ for (EditMode m : EditMode.values()) {
+ assertEquals(m, EditMode.fromKey(m.key()).orElseThrow(),
+ "fromKey(key()) must round-trip for " + m);
+ }
+ }
+
+ @Test
+ void fromKeyUnknownReturnsEmpty() {
+ assertTrue(EditMode.fromKey("nonExistent").isEmpty());
+ assertTrue(EditMode.fromKey(null).isEmpty());
+ }
+
+ @Test
+ void fromKeyOrDefaultFallsBackToDefault() {
+ assertEquals(EditMode.DEFAULT, EditMode.fromKeyOrDefault(null));
+ assertEquals(EditMode.DEFAULT, EditMode.fromKeyOrDefault("unknown"));
+ assertEquals(EditMode.ACCEPT_EDITS, EditMode.fromKeyOrDefault("acceptEdits"));
+ }
+
+ @Test
+ void bypassPermissionsKeyValue() {
+ assertEquals("bypassPermissions", EditMode.BYPASS_PERMISSIONS.key());
+ }
+
+ @Test
+ void allModesHaveUniqueKeys() {
+ long distinct = java.util.Arrays.stream(EditMode.values())
+ .map(EditMode::key)
+ .distinct()
+ .count();
+ assertEquals(EditMode.values().length, distinct, "All EditMode keys must be unique");
+ }
+}
diff --git a/src/test/java/io/github/nbclaudecodegui/process/ScreenContentDetectorTest.java b/src/test/java/io/github/nbclaudecodegui/process/ScreenContentDetectorTest.java
index d8350592..3d531d64 100644
--- a/src/test/java/io/github/nbclaudecodegui/process/ScreenContentDetectorTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/process/ScreenContentDetectorTest.java
@@ -1,6 +1,7 @@
package io.github.nbclaudecodegui.process;
import io.github.nbclaudecodegui.model.ChoiceMenuModel;
+import io.github.nbclaudecodegui.model.EditMode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
@@ -126,7 +127,7 @@ void detectSessionStateFromFixtures(String caseName, Path srcFile, Path expected
void detectEditModeEmptyWhenNoIndicatorNoEscPattern() {
// "esc to interrupt" without leading spaces → no mode indicator → Optional.empty()
List lines = List.of("Normal output", "esc to interrupt");
- java.util.Optional result = detector.detectEditMode(lines);
+ java.util.Optional result = detector.detectEditMode(lines);
assertTrue(result.isEmpty(), "Expected empty Optional when no mode indicator and no leading-space esc pattern");
}
@@ -136,9 +137,9 @@ void detectEditModePlanWhenPlanModeInFooter() {
"Some output",
"plan mode | esc to interrupt"
);
- java.util.Optional result = detector.detectEditMode(lines);
+ java.util.Optional result = detector.detectEditMode(lines);
assertTrue(result.isPresent());
- assertEquals("plan", result.get());
+ assertEquals(EditMode.PLAN, result.get());
}
@org.junit.jupiter.api.Test
@@ -149,9 +150,9 @@ void detectEditModePlanWithManyTrailingEmptyLines() {
"\u23F8 plan mode on | esc to interrupt"
));
for (int i = 0; i < 15; i++) lines.add("");
- java.util.Optional result = detector.detectEditMode(lines);
+ java.util.Optional result = detector.detectEditMode(lines);
assertTrue(result.isPresent());
- assertEquals("plan", result.get());
+ assertEquals(EditMode.PLAN, result.get());
}
@org.junit.jupiter.api.Test
@@ -161,9 +162,30 @@ void detectEditModeAcceptEditsWithTrailingEmptyLines() {
"\u23F5\u23F5 accept edits on | esc to interrupt"
));
for (int i = 0; i < 10; i++) lines.add("");
- java.util.Optional result = detector.detectEditMode(lines);
+ java.util.Optional result = detector.detectEditMode(lines);
assertTrue(result.isPresent());
- assertEquals("acceptEdits", result.get());
+ assertEquals(EditMode.ACCEPT_EDITS, result.get());
+ }
+
+ @org.junit.jupiter.api.Test
+ void detectEditModeBypassPermissions() {
+ // Screen text captured from Claude launched with --dangerously-skip-permissions
+ List lines = new ArrayList<>(List.of(
+ "Some output",
+ "\u23F5\u23F5 bypass permissions on (shift+tab to cycle)"
+ ));
+ for (int i = 0; i < 10; i++) lines.add("");
+ java.util.Optional result = detector.detectEditMode(lines);
+ assertTrue(result.isPresent());
+ assertEquals(EditMode.BYPASS_PERMISSIONS, result.get());
+ }
+
+ @org.junit.jupiter.api.Test
+ void detectEditModeBypassPermissionsCaseInsensitive() {
+ List lines = List.of("Some output", "Bypass Permissions on | esc to interrupt");
+ java.util.Optional result = detector.detectEditMode(lines);
+ assertTrue(result.isPresent());
+ assertEquals(EditMode.BYPASS_PERMISSIONS, result.get());
}
@org.junit.jupiter.api.Test
@@ -174,7 +196,7 @@ void detectEditModeEmptyWhenEscToInterruptNoLeadingSpaces() {
"esc to interrupt"
));
for (int i = 0; i < 15; i++) lines.add("");
- java.util.Optional result = detector.detectEditMode(lines);
+ java.util.Optional result = detector.detectEditMode(lines);
assertTrue(result.isEmpty(), "Expected empty Optional when esc line has no leading spaces");
}
@@ -186,9 +208,9 @@ void detectEditModeDefaultWhenEscToInterruptLeadingSpaces() {
" esc to interrupt"
));
for (int i = 0; i < 15; i++) lines.add("");
- java.util.Optional result = detector.detectEditMode(lines);
- assertTrue(result.isPresent(), "Expected Optional.of(\"default\") for leading-space esc pattern");
- assertEquals("default", result.get());
+ java.util.Optional result = detector.detectEditMode(lines);
+ assertTrue(result.isPresent(), "Expected Optional.of(DEFAULT) for leading-space esc pattern");
+ assertEquals(EditMode.DEFAULT, result.get());
}
@org.junit.jupiter.api.Test
diff --git a/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionTabBypassComboTest.java b/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionTabBypassComboTest.java
new file mode 100644
index 00000000..e33ca776
--- /dev/null
+++ b/src/test/java/io/github/nbclaudecodegui/ui/ClaudeSessionTabBypassComboTest.java
@@ -0,0 +1,76 @@
+package io.github.nbclaudecodegui.ui;
+
+import io.github.nbclaudecodegui.model.EditMode;
+import io.github.nbclaudecodegui.model.SessionLifecycle;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Regression tests for the bypass-permissions combo entry:
+ * once detected, the 4th "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).
+ */
+class ClaudeSessionTabBypassComboTest {
+
+ @Test
+ void bypassEntry_appearsWhenBypassDetected() {
+ ClaudeSessionTab tab = new ClaudeSessionTab();
+ assertEquals(ClaudeSessionTab.EDIT_MODE_VALUES.length, tab.editModeItemCount(),
+ "Initially combo should have 3 items");
+
+ tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS);
+ assertEquals(ClaudeSessionTab.EDIT_MODE_VALUES.length + 1, tab.editModeItemCount(),
+ "After BYPASS_PERMISSIONS, combo should have 4 items");
+ }
+
+ @Test
+ void bypassEntry_persistsAfterSwitchingToAnotherMode() {
+ ClaudeSessionTab tab = new ClaudeSessionTab();
+ tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS);
+ assertEquals(4, tab.editModeItemCount());
+
+ tab.onEditModeChanged(EditMode.PLAN);
+ assertEquals(4, tab.editModeItemCount(),
+ "Bypass entry must remain after switching to PLAN mode");
+
+ tab.onEditModeChanged(EditMode.DEFAULT);
+ assertEquals(4, tab.editModeItemCount(),
+ "Bypass entry must remain after switching to DEFAULT mode");
+
+ tab.onEditModeChanged(EditMode.ACCEPT_EDITS);
+ assertEquals(4, tab.editModeItemCount(),
+ "Bypass entry must remain after switching to ACCEPT_EDITS mode");
+ }
+
+ @Test
+ void bypassEntry_removedOnNewSession() {
+ ClaudeSessionTab tab = new ClaudeSessionTab();
+ tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS);
+ assertEquals(4, tab.editModeItemCount());
+
+ tab.onLifecycleChanged(SessionLifecycle.STARTING);
+ assertEquals(3, tab.editModeItemCount(),
+ "Bypass entry must be removed when a new session starts");
+ }
+
+ @Test
+ void bypassEntry_notAddedWithoutBypassSession() {
+ ClaudeSessionTab tab = new ClaudeSessionTab();
+ tab.onEditModeChanged(EditMode.PLAN);
+ tab.onEditModeChanged(EditMode.DEFAULT);
+ tab.onEditModeChanged(EditMode.ACCEPT_EDITS);
+ assertEquals(3, tab.editModeItemCount(),
+ "Bypass entry must not appear in a non-bypass session");
+ }
+
+ @Test
+ void bypassEntry_notAddedTwice() {
+ ClaudeSessionTab tab = new ClaudeSessionTab();
+ tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS);
+ tab.onEditModeChanged(EditMode.BYPASS_PERMISSIONS);
+ assertEquals(4, tab.editModeItemCount(),
+ "Bypass entry must not be added twice");
+ }
+}