From de9d7ebbcfc09308722644ef42b03c72de8270eb Mon Sep 17 00:00:00 2001 From: Muhammad Rizky Rizaldi Date: Fri, 16 Oct 2026 14:17:32 +0800 Subject: [PATCH] Fix mode cycling mid-prompt Signed-off-by: Muhammad Rizky Rizaldi --- CHANGELOG.md | 1 + claude-launch-tests/test_edit_mode_footer.py | 142 ++++++++++++++++++ pom.xml | 2 +- .../controller/ClaudeSessionController.java | 70 +++++++-- .../claudecodegui/model/EditMode.java | 3 +- .../process/ScreenContentDetector.java | 26 +++- .../ClaudeSessionControllerTest.java | 39 +++++ .../process/ScreenContentDetectorTest.java | 38 +++++ 8 files changed, 302 insertions(+), 19 deletions(-) create mode 100644 claude-launch-tests/test_edit_mode_footer.py 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 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.53-SNAPSHOT + 1.3.54-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 cc2bbc8..3d66ff4 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,15 @@ 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; + /** Poll interval (ms) per read attempt inside {@link #trySendShiftTabsUntilMode}; 1 in tests. */ int modeSwitchPollMs = 100; /** @@ -791,29 +800,45 @@ 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; + 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 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 057b7a0..08c45ad 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): *