diff --git a/CHANGELOG.md b/CHANGELOG.md
index 680879eb..c5fca45b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Fixed the edit-mode selector with Claude Code 2.1.x: it no longer jumps to "Ask on Edit" whenever Claude goes idle, and choosing a mode no longer makes Claude cycle through modes and stop on the wrong one; choosing another mode while a switch is still running now switches to that mode instead of cycling
- Fixed "OpenAI-compatible API" connection type being reset to "Claude-compatible API" on the Default profile (https://github.com/nbplugins/NetbeansPluginClaudeCodeGui/issues/191)
- Improved the IDE log (messages.log): per-request and per-click diagnostics (tab label polling, PreToolUse hooks with file contents, diff tabs, choice menus, permission prompts) are now written only when debug logging is enabled, so the log is no longer flooded and no longer contains edited code
diff --git a/claude-launch-tests/test_edit_mode_footer.py b/claude-launch-tests/test_edit_mode_footer.py
new file mode 100644
index 00000000..c3d540c8
--- /dev/null
+++ b/claude-launch-tests/test_edit_mode_footer.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""
+Capture the rendered footer (bottom screen lines) of the Claude Code TUI while
+cycling edit modes with Shift+Tab, both when idle and while Claude is working.
+
+Answers: which line is the bottom-most non-blank one in each mode, and does the
+mode indicator always live on it? ScreenContentDetector.detectEditMode() only
+inspects that single line, so anything rendered below the mode indicator makes
+the mode undetectable.
+
+Cross-platform: uses pywinpty on Windows, the stdlib pty elsewhere, and pyte to
+render the byte stream into a screen grid (the same view JediTerm gives the plugin).
+
+ pip install pyte pywinpty # pywinpty only on Windows
+ python test_edit_mode_footer.py [--work]
+"""
+
+import os
+import shutil
+import sys
+import tempfile
+import threading
+import time
+
+import pyte
+
+COLS, ROWS = 120, 40
+SHIFT_TAB = '\x1b[Z'
+
+
+class Term:
+ def __init__(self, argv, cwd):
+ env = dict(os.environ)
+ env.pop('CLAUDECODE', None)
+ env.pop('CLAUDE_CODE_ENTRYPOINT', None)
+ env['TERM'] = 'xterm-256color'
+ self.screen = pyte.Screen(COLS, ROWS)
+ self.stream = pyte.Stream(self.screen)
+ self.lock = threading.Lock()
+ if os.name == 'nt':
+ from winpty import PtyProcess
+ self.proc = PtyProcess.spawn(argv, cwd=cwd, env=env, dimensions=(ROWS, COLS))
+ self._read = lambda: self.proc.read(4096)
+ self._write = self.proc.write
+ else:
+ import pty
+ import subprocess
+ master, slave = pty.openpty()
+ import fcntl, struct, termios
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', ROWS, COLS, 0, 0))
+ self.proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=slave,
+ stdout=slave, stderr=slave, close_fds=True)
+ self._read = lambda: os.read(master, 4096).decode('utf-8', 'replace')
+ self._write = lambda s: os.write(master, s.encode())
+ threading.Thread(target=self._pump, daemon=True).start()
+
+ def _pump(self):
+ while True:
+ try:
+ data = self._read()
+ except Exception:
+ return
+ if not data:
+ time.sleep(0.01)
+ continue
+ with self.lock:
+ self.stream.feed(data)
+
+ def write(self, s):
+ self._write(s)
+
+ def lines(self):
+ with self.lock:
+ return [l.rstrip() for l in self.screen.display]
+
+ def bottom(self, n=4):
+ nb = [l for l in self.lines() if l.strip()]
+ return nb[-n:]
+
+ def wait_for(self, text, timeout=30):
+ end = time.time() + timeout
+ while time.time() < end:
+ if any(text in l for l in self.lines()):
+ return True
+ time.sleep(0.1)
+ return False
+
+ def close(self):
+ try:
+ if os.name == 'nt':
+ self.proc.terminate(force=True)
+ else:
+ self.proc.kill()
+ except Exception:
+ pass
+
+
+def dump(term, label):
+ print(f'--- {label}')
+ for l in term.bottom():
+ print(' |' + l)
+
+
+def main():
+ work = '--work' in sys.argv
+ claude = shutil.which('claude')
+ if claude is None:
+ sys.exit('claude not found on PATH')
+ cwd = tempfile.mkdtemp(prefix='cc-mode-footer-')
+ term = Term([claude], cwd)
+ try:
+ if term.wait_for('Yes, I trust', 15):
+ time.sleep(1)
+ term.write('\x1b[B') # default is "No, exit" — move to "Yes"
+ time.sleep(0.5)
+ term.write('\r')
+ if not term.wait_for('shift+tab', 30) and not term.wait_for('for shortcuts', 5):
+ dump(term, 'startup (no footer found)')
+ time.sleep(2)
+ dump(term, 'idle initial')
+ for i in range(5):
+ term.write(SHIFT_TAB)
+ time.sleep(1.2)
+ dump(term, f'idle after shift+tab #{i + 1}')
+
+ if work:
+ term.write('Count slowly from 1 to 40, one number per line, no tools.')
+ time.sleep(0.5)
+ term.write('\r')
+ time.sleep(2.5)
+ dump(term, 'working initial')
+ for i in range(5):
+ term.write(SHIFT_TAB)
+ time.sleep(1.2)
+ dump(term, f'working after shift+tab #{i + 1}')
+ term.wait_for('40', 60)
+ time.sleep(3)
+ dump(term, 'idle after response')
+ finally:
+ term.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/pom.xml b/pom.xml
index 3c223173..49581833 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbplugins
netbeans-plugin-claude-code-gui
- 1.3.56-SNAPSHOT
+ 1.3.59-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 cc2bbc82..ce2e7540 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java
@@ -113,6 +113,18 @@ public class ClaudeSessionController {
/** {@code true} while a shift-tab thread is actively switching the CC edit mode. */
private volatile boolean modeSwitchInProgress = false;
+ /** Guards {@link #editModeSwitchTarget}. */
+ private final Object editModeSwitchLock = new Object();
+
+ /**
+ * Mode the running {@link #sendShiftTabsUntilMode} worker is steering toward;
+ * {@code null} when no worker runs. Guarded by {@link #editModeSwitchLock}.
+ */
+ private EditMode editModeSwitchTarget;
+
+ /** Incremented on every switch request; tells a newer request from an equal one. Guarded by the lock. */
+ private long editModeSwitchGeneration;
+
/** Poll interval (ms) per read attempt inside {@link #trySendShiftTabsUntilMode}; 1 in tests. */
int modeSwitchPollMs = 100;
/**
@@ -759,7 +771,10 @@ public void sendShiftTab() {
} catch (IOException | InterruptedException ex) {
LOG.warning("sendShiftTab failed: " + ex.getMessage());
} finally {
- modeSwitchInProgress = false;
+ // a running sendShiftTabsUntilMode worker owns the flag
+ synchronized (editModeSwitchLock) {
+ if (editModeSwitchTarget == null) modeSwitchInProgress = false;
+ }
}
}, "shift-tab");
t.setDaemon(true);
@@ -791,29 +806,54 @@ public void onEditModeComboChanged(EditMode newMode) {
* {@link EditMode#BYPASS_PERMISSIONS} if available, then {@link EditMode#ACCEPT_EDITS}.
*
*
Runs on a daemon thread; sets lifecycle to WORKING at start and
- * READY on completion.
+ * READY on completion. At most one worker runs at a time: a request made while
+ * a switch is in progress only replaces its target, and the running worker
+ * continues to the new target once the current one is handled. (Two concurrent
+ * workers would each press Shift+Tab toward their own target, cycling endlessly.)
*
* @param targetMode the edit mode to reach
*/
private void sendShiftTabsUntilMode(EditMode targetMode) {
- if (connector == null) return;
+ if (!isConnected()) return;
+ final long startGeneration;
+ synchronized (editModeSwitchLock) {
+ boolean running = editModeSwitchTarget != null;
+ editModeSwitchTarget = targetMode;
+ startGeneration = ++editModeSwitchGeneration;
+ if (running) {
+ LOG.fine("sendShiftTabsUntilMode: switch in progress, retargeting to " + targetMode);
+ return;
+ }
+ modeSwitchInProgress = true;
+ }
model.setLifecycle(SessionLifecycle.WORKING);
- modeSwitchInProgress = true;
Thread t = new Thread(() -> {
+ EditMode target = targetMode;
+ long generation = startGeneration;
try {
- LOG.fine("sendShiftTabsUntilMode: target=" + targetMode
- + " currentModel=" + model.getEditMode());
- if (trySendShiftTabsUntilMode(targetMode)) return;
- // AUTO mode not found — older CC doesn't support it; try fallbacks
- if (targetMode == EditMode.AUTO) {
- LOG.warning("sendShiftTabsUntilMode: auto mode not available, trying fallbacks");
- if (trySendShiftTabsUntilMode(EditMode.BYPASS_PERMISSIONS)) return;
- trySendShiftTabsUntilMode(EditMode.ACCEPT_EDITS);
+ while (target != null) {
+ try {
+ switchEditMode(target);
+ } catch (IOException | RuntimeException ex) {
+ // a failed switch must not drop a newer request queued meanwhile
+ LOG.warning("sendShiftTabsUntilMode failed: " + ex.getMessage());
+ }
+ synchronized (editModeSwitchLock) {
+ if (editModeSwitchGeneration == generation) {
+ editModeSwitchTarget = null;
+ modeSwitchInProgress = false;
+ }
+ target = editModeSwitchTarget;
+ generation = editModeSwitchGeneration;
+ }
+ }
+ } catch (InterruptedException ex) {
+ LOG.warning("sendShiftTabsUntilMode interrupted");
+ synchronized (editModeSwitchLock) {
+ editModeSwitchTarget = null;
+ modeSwitchInProgress = false;
}
- } catch (IOException | InterruptedException ex) {
- LOG.warning("sendShiftTabsUntilMode failed: " + ex.getMessage());
} finally {
- modeSwitchInProgress = false;
onClaudeIdle();
}
}, "shift-tab-edit-mode");
@@ -821,6 +861,22 @@ private void sendShiftTabsUntilMode(EditMode targetMode) {
t.start();
}
+ /**
+ * Presses Shift+Tab until {@code targetMode} is reached; for {@link EditMode#AUTO}
+ * falls back to {@link EditMode#BYPASS_PERMISSIONS}, then {@link EditMode#ACCEPT_EDITS}.
+ */
+ private void switchEditMode(EditMode targetMode) throws IOException, InterruptedException {
+ LOG.fine("sendShiftTabsUntilMode: target=" + targetMode
+ + " currentModel=" + model.getEditMode());
+ if (trySendShiftTabsUntilMode(targetMode)) return;
+ // AUTO mode not found — older CC doesn't support it; try fallbacks
+ if (targetMode == EditMode.AUTO) {
+ LOG.warning("sendShiftTabsUntilMode: auto mode not available, trying fallbacks");
+ if (trySendShiftTabsUntilMode(EditMode.BYPASS_PERMISSIONS)) return;
+ trySendShiftTabsUntilMode(EditMode.ACCEPT_EDITS);
+ }
+ }
+
/**
* 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.
@@ -908,6 +964,11 @@ Optional detectCurrentMode() {
return screenContentDetector.detectEditMode(screenLines.get());
}
+ /** Package-private for testing: whether a PTY connector is active. */
+ boolean isConnected() {
+ return connector != null;
+ }
+
/** Package-private for testing: sends one Shift+Tab (ESC [ Z) to the PTY. */
void sendOneShiftTab() throws IOException {
connector.write(new byte[]{0x1b, '[', 'Z'});
diff --git a/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java b/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java
index 057b7a09..08c45adf 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/model/EditMode.java
@@ -13,7 +13,8 @@
* ordinal comparisons ({@code >=}, {@code <=}) express permission thresholds naturally.
*
* Screen-text mapping (used by {@code ScreenContentDetector.detectEditMode}), checked
- * against the single bottom-most non-blank screen line only:
+ * against the mode-footer line (the bottom-most non-blank line, or one just above it
+ * that starts with the {@code ⏸}/{@code ⏵} mode glyph):
*
* - {@link #PLAN} — bottom line contains {@code "plan mode"} or {@code "plan-mode"}
* - {@link #DEFAULT} — bottom line contains {@code "manual mode on"}, or (fallback for
diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java
index 1c959663..152e5c20 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java
@@ -501,9 +501,12 @@ private static boolean isBoxDrawingLine(String trimmed) {
/**
* Detect the Claude CLI edit mode from the rendered screen footer.
*
- *
Only the single bottom-most non-blank line is inspected — text further up the
- * screen (e.g. a permission-prompt tip or menu option that merely mentions a mode by
- * name) must never be mistaken for the actual mode-footer line.
+ *
The bottom-most non-blank line is inspected first. Claude Code 2.1.x renders
+ * extra footer lines below the mode indicator while idle (e.g. {@code ● high · /effort}),
+ * so the next two lines up are also checked — but only when they start with the
+ * mode glyph ({@code ⏸} or {@code ⏵}). Text further up the screen (e.g. a
+ * permission-prompt tip or menu option that merely mentions a mode by name) must
+ * never be mistaken for the actual mode-footer line.
*
*
Possible return values:
*
@@ -529,7 +532,20 @@ public Optional detectEditMode(List lines) {
if (lines == null || lines.isEmpty()) return Optional.empty();
List bottom = bottomNonBlankLines(lines, 3);
if (bottom.isEmpty()) return Optional.empty();
- String lower = bottom.get(0).toLowerCase();
+ Optional mode = editModeFromFooterLine(bottom.get(0));
+ if (mode.isPresent()) return mode;
+ for (int i = 1; i < bottom.size(); i++) {
+ String line = bottom.get(i).strip();
+ if (line.startsWith("⏸") || line.startsWith("⏵")) {
+ return editModeFromFooterLine(line);
+ }
+ }
+ return Optional.empty();
+ }
+
+ /** Maps a single mode-footer line to its {@link EditMode}; see {@link #detectEditMode}. */
+ private static Optional editModeFromFooterLine(String line) {
+ String lower = line.toLowerCase();
if (lower.contains("plan mode") || lower.contains("plan-mode")) {
return Optional.of(EditMode.PLAN);
}
@@ -548,7 +564,7 @@ public Optional detectEditMode(List lines) {
// " esc to interrupt" with two leading spaces is the reliable Ask/default-mode signal,
// kept as a fallback for older Claude Code versions that never emit "manual mode on".
// Without leading spaces the screen is in an unknown / transitioning state.
- if (bottom.get(0).startsWith(" esc to interrupt")) {
+ if (line.startsWith(" esc to interrupt")) {
return Optional.of(EditMode.DEFAULT);
}
return Optional.empty();
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 7da432e3..7cd2b478 100644
--- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java
+++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java
@@ -977,6 +977,143 @@ void trySendShiftTabsUntilMode_returnsfalseWhenModeNeverReached() throws Excepti
assertEquals(5, (int) tabsSent.get(), "5 tabs must have been sent");
}
+ /**
+ * Regression: selecting a second mode while a Shift+Tab switch is still running
+ * started a second worker thread. Both sent Shift+Tabs concurrently, each steering
+ * toward its own target, so the mode kept cycling and landed on an arbitrary mode.
+ * A new request must instead be picked up by the single running worker.
+ */
+ @Test
+ void onEditModeComboChanged_secondRequestDuringSwitch_isHandledBySameWorker() throws Exception {
+ // Simulated CC cycle (2.1.x order): DEFAULT → ACCEPT_EDITS → PLAN → AUTO → DEFAULT
+ EditMode[] cycle = {EditMode.DEFAULT, EditMode.ACCEPT_EDITS, EditMode.PLAN, EditMode.AUTO};
+ java.util.concurrent.atomic.AtomicInteger ccMode = new java.util.concurrent.atomic.AtomicInteger(0);
+ java.util.Set senders = java.util.concurrent.ConcurrentHashMap.newKeySet();
+ java.util.concurrent.CountDownLatch secondIssued = new java.util.concurrent.CountDownLatch(1);
+ ClaudeSessionModel m = new ClaudeSessionModel();
+ m.setEditMode(EditMode.DEFAULT);
+ ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
+ @Override boolean isConnected() { return true; }
+ @Override Optional detectCurrentMode() { return Optional.of(cycle[ccMode.get()]); }
+ @Override void sendOneShiftTab() {
+ senders.add(Thread.currentThread());
+ // hold worker 1 until the second request is issued, so the test is deterministic
+ try { secondIssued.await(2, java.util.concurrent.TimeUnit.SECONDS); }
+ catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+ ccMode.updateAndGet(i -> (i + 1) % cycle.length);
+ }
+ };
+ setModeSwitchPollMs(c, 1);
+
+ c.onEditModeComboChanged(EditMode.AUTO);
+ c.onEditModeComboChanged(EditMode.PLAN);
+ secondIssued.countDown();
+
+ java.lang.reflect.Field inProgress = ClaudeSessionController.class.getDeclaredField("modeSwitchInProgress");
+ inProgress.setAccessible(true);
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (inProgress.getBoolean(c) && System.currentTimeMillis() < deadline) {
+ Thread.sleep(5);
+ }
+
+ assertFalse(inProgress.getBoolean(c), "switch must finish");
+ assertEquals(EditMode.PLAN, cycle[ccMode.get()], "must end on the most recently requested mode");
+ assertEquals(1, senders.size(), "Shift+Tabs must be sent by a single worker thread");
+ }
+
+ /**
+ * Regression: an unchecked exception in the Shift+Tab worker (e.g. NPE when the
+ * connector is gone after the session stopped) left the switch target set and
+ * {@code modeSwitchInProgress} true forever, so no later switch ever ran and the
+ * screen-to-model mode sync stayed disabled.
+ */
+ @Test
+ void onEditModeComboChanged_uncheckedExceptionInWorker_doesNotBlockLaterSwitches() throws Exception {
+ java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
+ ClaudeSessionModel m = new ClaudeSessionModel();
+ m.setEditMode(EditMode.DEFAULT);
+ ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
+ @Override boolean isConnected() { return true; }
+ @Override Optional detectCurrentMode() { return Optional.of(EditMode.DEFAULT); }
+ @Override void sendOneShiftTab() {
+ if (calls.incrementAndGet() == 1) throw new NullPointerException("connector gone");
+ }
+ };
+ setModeSwitchPollMs(c, 1);
+ java.lang.reflect.Field inProgress = ClaudeSessionController.class.getDeclaredField("modeSwitchInProgress");
+ inProgress.setAccessible(true);
+
+ c.onEditModeComboChanged(EditMode.PLAN);
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (calls.get() < 1 && System.currentTimeMillis() < deadline) Thread.sleep(5);
+ Thread.sleep(200);
+ assertFalse(inProgress.getBoolean(c), "flag must be cleared after the worker failed");
+
+ c.onEditModeComboChanged(EditMode.ACCEPT_EDITS); // model already holds PLAN, so pick another mode
+ deadline = System.currentTimeMillis() + 5_000;
+ while (calls.get() < 2 && System.currentTimeMillis() < deadline) Thread.sleep(5);
+ assertTrue(calls.get() >= 2, "a new worker must start after the failed one");
+ }
+
+ @Test
+ void onEditModeComboChanged_failureOfActiveSwitch_stillServesQueuedRetarget() throws Exception {
+ java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
+ java.util.concurrent.CountDownLatch secondIssued = new java.util.concurrent.CountDownLatch(1);
+ ClaudeSessionModel m = new ClaudeSessionModel();
+ m.setEditMode(EditMode.DEFAULT);
+ ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
+ @Override boolean isConnected() { return true; }
+ @Override Optional detectCurrentMode() { return Optional.of(EditMode.DEFAULT); }
+ @Override void sendOneShiftTab() throws java.io.IOException {
+ if (calls.incrementAndGet() == 1) {
+ try { secondIssued.await(2, java.util.concurrent.TimeUnit.SECONDS); }
+ catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+ throw new java.io.IOException("write failed");
+ }
+ }
+ };
+ setModeSwitchPollMs(c, 1);
+
+ c.onEditModeComboChanged(EditMode.PLAN);
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (calls.get() < 1 && System.currentTimeMillis() < deadline) Thread.sleep(5);
+ c.onEditModeComboChanged(EditMode.ACCEPT_EDITS); // queued while PLAN switch is active
+ secondIssued.countDown();
+ deadline = System.currentTimeMillis() + 5_000;
+ while (calls.get() < 2 && System.currentTimeMillis() < deadline) Thread.sleep(5);
+ assertTrue(calls.get() >= 2, "queued retarget must be served after the active switch failed");
+ }
+
+ @Test
+ void onEditModeComboChanged_sameModeRequestedAgainDuringFailingSwitch_isStillServed() throws Exception {
+ java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
+ java.util.concurrent.CountDownLatch requestsIssued = new java.util.concurrent.CountDownLatch(1);
+ ClaudeSessionModel m = new ClaudeSessionModel();
+ m.setEditMode(EditMode.DEFAULT);
+ ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
+ @Override boolean isConnected() { return true; }
+ @Override Optional detectCurrentMode() { return Optional.of(EditMode.DEFAULT); }
+ @Override void sendOneShiftTab() throws java.io.IOException {
+ if (calls.incrementAndGet() == 1) {
+ try { requestsIssued.await(2, java.util.concurrent.TimeUnit.SECONDS); }
+ catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+ throw new java.io.IOException("write failed");
+ }
+ }
+ };
+ setModeSwitchPollMs(c, 1);
+
+ c.onEditModeComboChanged(EditMode.PLAN); // A, fails
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (calls.get() < 1 && System.currentTimeMillis() < deadline) Thread.sleep(5);
+ c.onEditModeComboChanged(EditMode.ACCEPT_EDITS); // B
+ c.onEditModeComboChanged(EditMode.PLAN); // A again: slot equals the failed target
+ requestsIssued.countDown();
+ deadline = System.currentTimeMillis() + 5_000;
+ while (calls.get() < 2 && System.currentTimeMillis() < deadline) Thread.sleep(5);
+ assertTrue(calls.get() >= 2, "the latest request must be served even if it equals the failed target");
+ }
+
private static void setModeSwitchPollMs(ClaudeSessionController c, int ms) throws Exception {
java.lang.reflect.Field f = ClaudeSessionController.class.getDeclaredField("modeSwitchPollMs");
f.setAccessible(true);
diff --git a/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java b/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java
index b0e816ce..c5a9cd72 100644
--- a/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java
+++ b/src/test/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetectorTest.java
@@ -291,6 +291,44 @@ void detectEditModeManualModeCaseInsensitive() {
assertEquals(EditMode.DEFAULT, result.get());
}
+ /**
+ * Regression: Claude Code 2.1.x renders the effort indicator on its own line
+ * below the mode indicator while idle (captured by
+ * claude-launch-tests/test_edit_mode_footer.py). Inspecting only the bottom-most
+ * line made every idle mode undetectable, so the combo fell back to "Ask on Edit"
+ * whenever Claude went idle and Shift+Tab switching cycled blindly.
+ */
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.CsvSource(delimiter = '|', value = {
+ " ⏵⏵ auto mode on (shift+tab to cycle) 0 tokens | AUTO",
+ " ⏸ manual mode on · ? for shortcuts 0 tokens | DEFAULT",
+ " ⏵⏵ accept edits on (shift+tab to cycle) 0 tokens | ACCEPT_EDITS",
+ " ⏸ plan mode on (shift+tab to cycle) 0 tokens | PLAN",
+ " ⏵⏵ bypass permissions on (shift+tab to cycle) 0 tokens | BYPASS_PERMISSIONS"
+ })
+ void detectEditModeWhenEffortLineRenderedBelowModeLine(String modeLine, EditMode expected) {
+ List lines = new ArrayList<>(List.of(
+ "❯ ",
+ "────────────────────────────────────────────────────────────",
+ modeLine,
+ " ● high · /effort"
+ ));
+ for (int i = 0; i < 5; i++) lines.add("");
+ assertEquals(java.util.Optional.of(expected), detector.detectEditMode(lines));
+ }
+
+ @org.junit.jupiter.api.Test
+ void detectEditModeNotAcceptEditsWhenOnlyMentionedInMenuOptionAboveBottom() {
+ // A line above the bottom one is only trusted when it starts with the mode glyph;
+ // a menu option that merely mentions a mode must not be picked up.
+ List lines = List.of(
+ " Would you like to proceed?",
+ " ❯ 1. Yes, and auto-accept edits",
+ " 2. Yes, and manually approve edits",
+ " 3. No, keep planning");
+ assertTrue(detector.detectEditMode(lines).isEmpty());
+ }
+
// -------------------------------------------------------------------------
// detectPlanName
// -------------------------------------------------------------------------