From 8e19adea08695c6f5c3de27aa7b45b27454a2689 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 1 Jun 2026 20:08:16 +0300 Subject: [PATCH] Fixed mode switching becoming unreliable while Claude Code is actively working The edit-mode selector (Shift+Tab cycling) failed to reach the target mode when Claude Code was generating output. Root cause: the plugin was sending multiple Shift+Tab keystrokes too quickly, causing them to accumulate in CC's input buffer. CC then processed them all at once, flying past the target mode with the intermediate PLAN indicator visible for ~0 ms. Fix: replaced the fixed-interval polling loop in trySendShiftTabsUntilMode with a "wait for concrete mode change" approach. Each Shift+Tab is now sent only after observing a concrete (non-empty) mode change caused by the previous tab, or after a 1500 ms timeout. This prevents accumulation: at most one tab is in the buffer at any time. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + pom.xml | 2 +- .../controller/ClaudeSessionController.java | 90 +++++++++-- .../ClaudeSessionControllerTest.java | 140 ++++++++++++++++++ 4 files changed, 217 insertions(+), 16 deletions(-) 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" // -------------------------------------------------------------------------