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"
// -------------------------------------------------------------------------