diff --git a/CHANGELOG.md b/CHANGELOG.md index df282b71..be3a7de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed mode switching (e.g. auto → plan) becoming unreliable when Claude Code is actively working: the edit-mode selector now correctly reaches the target mode without overshooting - Fixed stale model aliases from a "Claude Compatible API" profile still showing in the model selector after switching the profile to "Managed by Claude" - Fixed ClassNotFoundException on plugin installation/upgrade when the session tab had been previously saved under the old package name (https://github.com/nbplugins/NetbeansPluginClaudeCodeGui/issues/146) - Fixed a Warning dialog appearing on NetBeans exit when a diff tab had been opened during the session diff --git a/pom.xml b/pom.xml index 15626ccf..72961232 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.2.16-SNAPSHOT + 1.2.18-SNAPSHOT nbm Netbeans Plugin Claude Code GUI NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE. diff --git a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java index bdd835ad..2744fc05 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -108,6 +108,14 @@ public class ClaudeSessionController { /** {@code true} while a shift-tab thread is actively switching the CC edit mode. */ private volatile boolean modeSwitchInProgress = false; + /** Poll interval (ms) per read attempt inside {@link #trySendShiftTabsUntilMode}; 1 in tests. */ + int modeSwitchPollMs = 100; + /** + * Max polls waiting for a concrete mode change after each Shift+Tab. + * 15 × 100 ms = 1 500 ms — enough for CC to process a buffered keystroke during WORKING. + */ + int modeSwitchPollCount = 15; + /** * Set to {@code true} when model discovery starts to prevent re-entry; * reset to {@code false} if the result is empty (to allow a retry). @@ -754,30 +762,82 @@ private void sendShiftTabsUntilMode(EditMode targetMode) { /** * Inner loop: sends up to 5 Shift+Tab presses and returns {@code true} when * {@code targetMode} is detected on screen, {@code false} if not reached. + * + *

Key design: after each Shift+Tab press the loop waits for a + * concrete mode change (a non-empty {@link EditMode} that differs from + * the last known mode) rather than polling for a fixed number of intervals. + * This prevents tab accumulation — the next tab is only sent once the current + * tab's effect has been observed, so CC never receives multiple buffered + * keystrokes that it processes in rapid succession (which would cause PLAN to + * flash for 0 ms and be immediately overridden by the next tab). + * + *

If no concrete change is seen within {@link #modeSwitchPollCount} × + * {@link #modeSwitchPollMs} ms (default 1 500 ms), the tab is assumed to have + * been processed into a mode that produces no on-screen marker (e.g. DEFAULT + * during WORKING state), and the next tab is sent. */ - private boolean trySendShiftTabsUntilMode(EditMode targetMode) + boolean trySendShiftTabsUntilMode(EditMode targetMode) throws IOException, InterruptedException { + Optional initial = detectCurrentMode(); + LOG.fine("trySendShiftTabsUntilMode: target=" + targetMode + + " initial=" + initial.map(EditMode::key).orElse("(empty)")); + if (isModeAtTarget(initial, targetMode)) return true; + + Optional lastConcrete = initial; for (int attempt = 0; attempt < 5; attempt++) { - connector.write(new byte[]{0x1b, '[', 'Z'}); - Thread.sleep(200); - Optional detected = - screenContentDetector.detectEditMode(screenLines.get()); - LOG.fine("trySendShiftTabsUntilMode: target=" + targetMode + " attempt=" + attempt - + " detected=" + detected.map(EditMode::key).orElse("(empty)")); - if (detected.isPresent() && detected.get() == targetMode) { - LOG.fine("trySendShiftTabsUntilMode: reached " + targetMode + " on attempt=" + attempt); - return true; - } - // DEFAULT mode has no idle-screen marker — empty detection means we are in default - if (targetMode == EditMode.DEFAULT && detected.isEmpty()) { - LOG.fine("trySendShiftTabsUntilMode: no mode marker → treating as default on attempt=" + attempt); - return true; + sendOneShiftTab(); + Optional next = waitForConcreteModeChange(lastConcrete); + LOG.fine("trySendShiftTabsUntilMode: target=" + targetMode + + " attempt=" + attempt + + " before=" + lastConcrete.map(EditMode::key).orElse("(empty)") + + " after=" + next.map(EditMode::key).orElse("(timeout)")); + if (isModeAtTarget(next, targetMode)) return true; + if (next.isPresent()) { + lastConcrete = next; } + // next.isEmpty() = timed out: tab was processed but result is invisible + // (e.g. DEFAULT during WORKING). Proceed to next tab. } LOG.warning("trySendShiftTabsUntilMode: did not reach " + targetMode + " after 5 attempts"); return false; } + /** + * Polls the screen up to {@link #modeSwitchPollCount} times, returning as soon + * as a concrete (non-empty) mode that differs from {@code before} is detected. + * Returns {@link Optional#empty()} on timeout. + */ + private Optional waitForConcreteModeChange(Optional before) + throws InterruptedException { + for (int poll = 0; poll < modeSwitchPollCount; poll++) { + Thread.sleep(modeSwitchPollMs); + Optional detected = detectCurrentMode(); + LOG.fine("trySendShiftTabsUntilMode poll=" + poll + + " detected=" + detected.map(EditMode::key).orElse("(empty)")); + if (detected.isPresent() && !detected.equals(before)) { + return detected; + } + } + return Optional.empty(); + } + + /** Returns {@code true} if {@code detected} corresponds to {@code target}. */ + private boolean isModeAtTarget(Optional detected, EditMode target) { + if (detected.isPresent() && detected.get() == target) return true; + // DEFAULT has no on-screen marker; empty detection = in default. + return target == EditMode.DEFAULT && detected.isEmpty(); + } + + /** Package-private for testing: reads the current edit mode from the terminal screen. */ + Optional detectCurrentMode() { + return screenContentDetector.detectEditMode(screenLines.get()); + } + + /** Package-private for testing: sends one Shift+Tab (ESC [ Z) to the PTY. */ + void sendOneShiftTab() throws IOException { + connector.write(new byte[]{0x1b, '[', 'Z'}); + } + // ------------------------------------------------------------------------- // Model switching // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java index 800ff659..6960b7e3 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -10,6 +10,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -511,6 +512,145 @@ void computeToggles_alreadyMatchingDesired_noToggles() { assertEquals(List.of(), toggles); } + // ------------------------------------------------------------------------- + // trySendShiftTabsUntilMode — read-first, multi-poll fix + // ------------------------------------------------------------------------- + + /** + * Regression: when already at the target mode, no Shift+Tab must be sent. + * Old code always sent a tab first; that overshoot the mode cycle. + */ + @Test + void trySendShiftTabsUntilMode_alreadyAtTarget_noTabSent() throws Exception { + AtomicReference tabsSent = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { return Optional.of(EditMode.PLAN); } + @Override void sendOneShiftTab() { tabsSent.set(tabsSent.get() + 1); } + }; + setModeSwitchPollMs(c, 1); + + boolean result = invokeTrySend(c, EditMode.PLAN); + + assertTrue(result, "must return true when already at target"); + assertEquals(0, (int) tabsSent.get(), "no tab should be sent when already at target"); + } + + /** + * Regression: when target is DEFAULT and screen has no mode marker, + * the initial read must recognise it as "already in default" and not send any tab. + */ + @Test + void trySendShiftTabsUntilMode_alreadyDefault_noTabSent() throws Exception { + AtomicReference tabsSent = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { return Optional.empty(); } + @Override void sendOneShiftTab() { tabsSent.set(tabsSent.get() + 1); } + }; + setModeSwitchPollMs(c, 1); + + boolean result = invokeTrySend(c, EditMode.DEFAULT); + + assertTrue(result, "must return true when screen has no mode marker and target is DEFAULT"); + assertEquals(0, (int) tabsSent.get(), "no tab should be sent when already in default"); + } + + /** + * Regression: prevents tab accumulation — only sends the next tab after observing + * a concrete mode change caused by the current tab. + * + * Sequence: initial=AUTO, tab-0→DEFAULT(empty during WORKING, timeout after 15 polls), + * tab-1→ACCEPT_EDITS, tab-2→PLAN. + */ + @Test + void trySendShiftTabsUntilMode_waitsForConcreteChangeBeforeNextTab() throws Exception { + AtomicReference tabsSent = new AtomicReference<>(0); + // Simulate: initial=AUTO, after tab-0: 15 empty polls (DEFAULT invisible during WORKING) + // then after tab-1: ACCEPT_EDITS appears on poll-3, then after tab-2: PLAN on poll-2. + AtomicReference pollCount = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { + int tabs = tabsSent.get(); + int poll = pollCount.updateAndGet(v -> v + 1); + if (tabs == 0) return Optional.of(EditMode.AUTO); // initial read + if (tabs == 1) return Optional.empty(); // tab-0: DEFAULT invisible + if (tabs == 2) return poll <= 18 ? Optional.empty() + : Optional.of(EditMode.ACCEPT_EDITS); // tab-1: ACCEPT_EDITS at poll 3 + return Optional.of(EditMode.PLAN); // tab-2: PLAN immediately + } + @Override void sendOneShiftTab() { + pollCount.set(0); // reset poll counter for each new tab + tabsSent.updateAndGet(v -> v + 1); + } + }; + setModeSwitchPollMs(c, 1); + + boolean result = invokeTrySend(c, EditMode.PLAN); + + assertTrue(result, "must reach PLAN"); + assertEquals(3, (int) tabsSent.get(), "exactly 3 tabs needed: AUTO→DEFAULT→ACCEPT_EDITS→PLAN"); + } + + /** + * When the mode change is immediately visible (e.g. CC is idle), only one tab + * is sent and the target is detected on the first poll. + */ + @Test + void trySendShiftTabsUntilMode_stopsAtTargetWithoutOvershoot() throws Exception { + AtomicReference tabsSent = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { + return tabsSent.get() == 0 + ? Optional.of(EditMode.ACCEPT_EDITS) // initial + : Optional.of(EditMode.PLAN); // after first tab + } + @Override void sendOneShiftTab() { tabsSent.updateAndGet(v -> v + 1); } + }; + setModeSwitchPollMs(c, 1); + + boolean result = invokeTrySend(c, EditMode.PLAN); + + assertTrue(result, "must return true on detecting PLAN"); + assertEquals(1, (int) tabsSent.get(), "exactly one tab needed"); + } + + /** + * When the mode is never reached (e.g. CC doesn't support it), must return + * false after 5 attempts. Each attempt times out (returns empty = no concrete change). + */ + @Test + void trySendShiftTabsUntilMode_returnsfalseWhenModeNeverReached() throws Exception { + AtomicReference tabsSent = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + // initial=ACCEPT_EDITS, always stays ACCEPT_EDITS → never see a concrete different mode + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { return Optional.of(EditMode.ACCEPT_EDITS); } + @Override void sendOneShiftTab() { tabsSent.updateAndGet(v -> v + 1); } + }; + setModeSwitchPollMs(c, 1); + + boolean result = invokeTrySend(c, EditMode.PLAN); + + assertFalse(result, "must return false after 5 failed attempts"); + assertEquals(5, (int) tabsSent.get(), "5 tabs must have been sent"); + } + + private static void setModeSwitchPollMs(ClaudeSessionController c, int ms) throws Exception { + java.lang.reflect.Field f = ClaudeSessionController.class.getDeclaredField("modeSwitchPollMs"); + f.setAccessible(true); + f.setInt(c, ms); + } + + private static boolean invokeTrySend(ClaudeSessionController c, EditMode target) throws Exception { + java.lang.reflect.Method m = ClaudeSessionController.class + .getDeclaredMethod("trySendShiftTabsUntilMode", EditMode.class); + m.setAccessible(true); + return (boolean) m.invoke(c, target); + } + // ------------------------------------------------------------------------- // sendShiftTabsUntilMode — empty detectEditMode must be treated as "default" // -------------------------------------------------------------------------