Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbplugins</groupId>
<artifactId>netbeans-plugin-claude-code-gui</artifactId>
<version>1.2.16-SNAPSHOT</version>
<version>1.2.18-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Plugin Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
*
* <p>Key design: after each Shift+Tab press the loop waits for a
* <em>concrete mode change</em> (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).
*
* <p>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<EditMode> initial = detectCurrentMode();
LOG.fine("trySendShiftTabsUntilMode: target=" + targetMode
+ " initial=" + initial.map(EditMode::key).orElse("(empty)"));
if (isModeAtTarget(initial, targetMode)) return true;

Optional<EditMode> lastConcrete = initial;
for (int attempt = 0; attempt < 5; attempt++) {
connector.write(new byte[]{0x1b, '[', 'Z'});
Thread.sleep(200);
Optional<EditMode> 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<EditMode> 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<EditMode> waitForConcreteModeChange(Optional<EditMode> before)
throws InterruptedException {
for (int poll = 0; poll < modeSwitchPollCount; poll++) {
Thread.sleep(modeSwitchPollMs);
Optional<EditMode> 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<EditMode> 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<EditMode> 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
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Integer> tabsSent = new AtomicReference<>(0);
ClaudeSessionModel m = new ClaudeSessionModel();
ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
@Override Optional<EditMode> 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<Integer> tabsSent = new AtomicReference<>(0);
ClaudeSessionModel m = new ClaudeSessionModel();
ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
@Override Optional<EditMode> 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<Integer> 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<Integer> pollCount = new AtomicReference<>(0);
ClaudeSessionModel m = new ClaudeSessionModel();
ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
@Override Optional<EditMode> 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<Integer> tabsSent = new AtomicReference<>(0);
ClaudeSessionModel m = new ClaudeSessionModel();
ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
@Override Optional<EditMode> 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<Integer> 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<EditMode> 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"
// -------------------------------------------------------------------------
Expand Down
Loading