Skip to content
Open
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 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)
Expand Down
142 changes: 142 additions & 0 deletions claude-launch-tests/test_edit_mode_footer.py
Original file line number Diff line number Diff line change
@@ -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()
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.3.53-SNAPSHOT</version>
<version>1.3.54-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 @@ -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;
/**
Expand Down Expand Up @@ -791,36 +800,68 @@ public void onEditModeComboChanged(EditMode newMode) {
* {@link EditMode#BYPASS_PERMISSIONS} if available, then {@link EditMode#ACCEPT_EDITS}.
*
* <p>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");
t.setDaemon(true);
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.
Expand Down Expand Up @@ -908,6 +949,11 @@ Optional<EditMode> 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'});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* ordinal comparisons ({@code >=}, {@code <=}) express permission thresholds naturally.
*
* <p>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):
* <ul>
* <li>{@link #PLAN} — bottom line contains {@code "plan mode"} or {@code "plan-mode"}</li>
* <li>{@link #DEFAULT} — bottom line contains {@code "manual mode on"}, or (fallback for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,9 +501,12 @@ private static boolean isBoxDrawingLine(String trimmed) {
/**
* Detect the Claude CLI edit mode from the rendered screen footer.
*
* <p>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.
* <p>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.
*
* <p>Possible return values:
* <ul>
Expand All @@ -529,7 +532,20 @@ public Optional<EditMode> detectEditMode(List<String> lines) {
if (lines == null || lines.isEmpty()) return Optional.empty();
List<String> bottom = bottomNonBlankLines(lines, 3);
if (bottom.isEmpty()) return Optional.empty();
String lower = bottom.get(0).toLowerCase();
Optional<EditMode> 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<EditMode> editModeFromFooterLine(String line) {
String lower = line.toLowerCase();
if (lower.contains("plan mode") || lower.contains("plan-mode")) {
return Optional.of(EditMode.PLAN);
}
Expand All @@ -548,7 +564,7 @@ public Optional<EditMode> detectEditMode(List<String> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,45 @@ 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<Thread> senders = java.util.concurrent.ConcurrentHashMap.newKeySet();
ClaudeSessionModel m = new ClaudeSessionModel();
m.setEditMode(EditMode.DEFAULT);
ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) {
@Override boolean isConnected() { return true; }
@Override Optional<EditMode> detectCurrentMode() { return Optional.of(cycle[ccMode.get()]); }
@Override void sendOneShiftTab() {
senders.add(Thread.currentThread());
ccMode.updateAndGet(i -> (i + 1) % cycle.length);
}
};
setModeSwitchPollMs(c, 1);

c.onEditModeComboChanged(EditMode.AUTO);
c.onEditModeComboChanged(EditMode.PLAN);

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");
}

private static void setModeSwitchPollMs(ClaudeSessionController c, int ms) throws Exception {
java.lang.reflect.Field f = ClaudeSessionController.class.getDeclaredField("modeSwitchPollMs");
f.setAccessible(true);
Expand Down
Loading