diff --git a/CHANGELOG.md b/CHANGELOG.md
index f27d061..e148b26 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
- 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
# 1.3.28 (2026-10-07)
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 0000000..d85404d
--- /dev/null
+++ b/claude-launch-tests/test_edit_mode_footer.py
@@ -0,0 +1,142 @@
+#!/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')
+ 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 ca0c1a1..b949b10 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
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;
+ synchronized (editModeSwitchLock) {
+ boolean running = editModeSwitchTarget != null;
+ editModeSwitchTarget = targetMode;
+ 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;
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) {
+ switchEditMode(target);
+ synchronized (editModeSwitchLock) {
+ if (editModeSwitchTarget == target) {
+ editModeSwitchTarget = null;
+ modeSwitchInProgress = false;
+ }
+ target = editModeSwitchTarget;
+ }
}
} catch (IOException | InterruptedException ex) {
LOG.warning("sendShiftTabsUntilMode failed: " + ex.getMessage());
+ synchronized (editModeSwitchLock) {
+ editModeSwitchTarget = null;
+ modeSwitchInProgress = false;
+ }
} finally {
- modeSwitchInProgress = false;
onClaudeIdle();
}
}, "shift-tab-edit-mode");
@@ -821,6 +846,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 +949,11 @@ Optional 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):
* 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