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}): + *

+ */ +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: + *

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