diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd03b6..1367c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed switching the edit mode (e.g. to Auto Mode) while a prompt was still running sometimes cycling through several modes and landing on the wrong one instead of the one selected - Fixed the model dropdown in the status bar being unable to shrink below roughly half the panel's width — it was inflating its minimum size to fit the widest previously-seen item, even a stale oversized one left over from a misparsed model list (https://github.com/nbplugins/NetbeansPluginClaudeCodeGui/issues/176) - Fixed the model dropdown showing full descriptions (including the checkmark and effort text) instead of short model names with newer Claude Code CLI versions that add an extra space after the option number (e.g. "1. Default (recommended)") (https://github.com/nbplugins/NetbeansPluginClaudeCodeGui/issues/176) - Fixed an unnumbered choice prompt (e.g. the "trust this folder" dialog) showing every line of the surrounding text as a bogus selectable option instead of just the real choices, and losing the "Yes" option entirely by merging it into the "No" option's description diff --git a/pom.xml b/pom.xml index 8be77d2..28f763b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.36-SNAPSHOT + 1.3.37-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 9b9ee8d..4616523 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -141,6 +141,13 @@ public class ClaudeSessionController { */ private ChoiceMenuModel pendingChoiceMenuCandidate; + /** + * A newly detected edit mode that differs from the model's current mode, held + * pending a second consecutive confirming detection before it replaces the + * current mode. See {@link #applyDetectedEditMode}. + */ + private Optional pendingEditModeCandidate = Optional.empty(); + /** * Rolling buffer of the last {@link #PTY_LINE_BUFFER_SIZE} stripped PTY lines. * Used as a fallback for prompt detection when the JediTerm screen buffer is @@ -818,18 +825,31 @@ boolean trySendShiftTabsUntilMode(EditMode targetMode) /** * 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. + * as a concrete (non-empty) mode that differs from {@code before} is detected on + * two consecutive polls. Returns {@link Optional#empty()} on timeout. + * + *

Requiring two consecutive agreeing polls (rather than acting on the first + * differing reading) guards against a single screen-poll tick catching Claude's + * Ink renderer mid-repaint of the footer — a one-off glitch frame that shows an + * unrelated mode would otherwise be mistaken for the Shift+Tab's real effect, + * desynchronizing this loop's notion of "current mode" from what CC actually + * shows and causing the final mode to land somewhere other than {@code targetMode}. */ private Optional waitForConcreteModeChange(Optional before) throws InterruptedException { + Optional candidate = Optional.empty(); for (int poll = 0; poll < modeSwitchPollCount; poll++) { Thread.sleep(modeSwitchPollMs); Optional detected = detectCurrentMode(); LOG.fine("trySendShiftTabsUntilMode poll=" + poll + " detected=" + detected.map(EditMode::key).orElse("(empty)")); if (detected.isPresent() && !detected.equals(before)) { - return detected; + if (candidate.isPresent() && candidate.equals(detected)) { + return detected; + } + candidate = detected; + } else { + candidate = Optional.empty(); } } return Optional.empty(); @@ -1164,20 +1184,7 @@ void pollScreenState() { // Continuously sync CC screen mode → model (skip during switches and discovery) if (modelComboPopulated && !modeSwitchInProgress && !modelDiscoveryInProgress) { - Optional detected = screenContentDetector.detectEditMode(lines); - LOG.fine("[pollScreenState] editMode sync: detected=" + detected.orElse(null) + " current=" + model.getEditMode()); - if (detected.isPresent()) { - EditMode mode = detected.get(); - if (mode != model.getEditMode()) { - model.setEditMode(mode); - } - } else if (model.getLifecycle() != SessionLifecycle.WORKING) { - // Unknown mode outside WORKING (screen transitioning or idle with no indicator) - // → treat as Ask/default. During WORKING: preserve current registry value. - if (model.getEditMode() != EditMode.DEFAULT) { - model.setEditMode(EditMode.DEFAULT); - } - } + applyDetectedEditMode(screenContentDetector.detectEditMode(lines)); } } @@ -1375,6 +1382,54 @@ private ChoiceMenuModel detectCurrentMenu() { * @param newMenu the menu detected on this poll * @param logTag short prefix identifying the calling poll path, for LOG.fine messages */ + /** + * Applies a newly detected edit mode to the model, or — if it differs from both + * the current mode and any already-pending candidate — withholds it until the + * same mode is confirmed by a second consecutive detection. + * + *

Claude's Ink renderer repaints its footer using cursor-movement escape + * sequences; a single screen-poll tick can catch that repaint mid-flight and read + * a reflowed or stale line, producing a one-off incorrect {@link EditMode} even + * though the real footer (and the mode itself) never actually changed. Applying + * every single-poll detection immediately (as before this debounce) could flip + * the model to that glitch mode; the next poll would then see the real mode again + * and flip back, visible to the user as the mode briefly cycling through unrelated + * values. Requiring the same new mode on two consecutive polls — the same pattern + * used for choice-menu replacement in {@link #applyDetectedChoiceMenu} — means a + * one-off glitch frame never survives to the next poll and is never applied. + * + * @param detected the mode detected on this poll, or empty if the screen shows no + * recognizable mode indicator (idle/transitioning, or WORKING with + * no visible marker) + */ + private void applyDetectedEditMode(Optional detected) { + LOG.fine("[pollScreenState] editMode sync: detected=" + detected.orElse(null) + " current=" + model.getEditMode()); + if (detected.isEmpty()) { + if (model.getLifecycle() == SessionLifecycle.WORKING) { + // No on-screen marker while working is the expected steady state + // (not a glitch) — preserve the current mode and drop any pending candidate. + pendingEditModeCandidate = Optional.empty(); + return; + } + // Unknown mode outside WORKING (screen transitioning or idle with no indicator) + // → treat as Ask/default. + detected = Optional.of(EditMode.DEFAULT); + } + EditMode mode = detected.get(); + if (mode == model.getEditMode()) { + pendingEditModeCandidate = Optional.empty(); + return; + } + if (pendingEditModeCandidate.isPresent() && pendingEditModeCandidate.get() == mode) { + LOG.fine("[pollScreenState] editMode confirmed: " + mode); + pendingEditModeCandidate = Optional.empty(); + model.setEditMode(mode); + } else { + LOG.fine("[pollScreenState] editMode candidate, awaiting confirmation: " + mode); + pendingEditModeCandidate = Optional.of(mode); + } + } + private void applyDetectedChoiceMenu(ChoiceMenuModel newMenu, String logTag) { ChoiceMenuModel current = model.getActiveChoiceMenu(); if (current == null) { 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 f32ecc2..b8bffab 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -164,7 +164,9 @@ void pollScreenStateDoesNotOverwriteAcceptEditsWhileWorking() throws Exception { /** * Regression guard: if the screen shows a concrete mode (e.g. "plan mode" disappears * and nothing replaces it → "default") while WORKING, the model must NOT be overwritten - * with "default" — but if the screen explicitly shows "plan mode" text it must still update. + * with "default" — but if the screen explicitly shows "plan mode" text it must still update + * (once confirmed twice — see {@link #pollScreenStateEditModeAppliesAfterTwoConsecutiveConfirmations} + * for why a single poll is no longer enough on its own). */ @Test void pollScreenStateDetectsPlanModeChangeWhileWorking() throws Exception { @@ -187,9 +189,87 @@ void pollScreenStateDetectsPlanModeChangeWhileWorking() throws Exception { f.setBoolean(c3, true); c3.pollScreenState(); + assertEquals(EditMode.DEFAULT, m3.getEditMode(), + "first poll must only register PLAN as a pending candidate, not apply it yet"); + c3.pollScreenState(); assertEquals(EditMode.PLAN, m3.getEditMode(), - "plan mode must be detected even during WORKING when text is visible"); + "plan mode must be detected even during WORKING once confirmed on a second consecutive poll"); + } + + // ------------------------------------------------------------------------- + // pollScreenState — edit-mode debounce: a single glitch poll must not flip + // the model; the same new mode confirmed twice must be applied. + // + // Regression: Claude's Ink renderer repaints the footer via cursor-movement + // escapes, which can transiently reflow the bottom line for a single poll + // tick. Applying every single-poll detection immediately could flip the model + // to a glitch mode and back on the very next poll, visible to the user as the + // mode briefly cycling through unrelated values and sometimes settling on the + // wrong one. + // ------------------------------------------------------------------------- + + @Test + void pollScreenStateEditModeDoesNotFlipOnSingleGlitchPoll() throws Exception { + List acceptEditsScreen = Collections.singletonList( + "⏵⏵ accept edits on (shift+tab to cycle)"); + List glitchScreen = Collections.singletonList( + "⏵⏵ auto mode on (shift+tab to cycle)"); + AtomicReference> screenRef = new AtomicReference<>(acceptEditsScreen); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, screenRef::get); + m.setWorkingDirectory(new java.io.File("/tmp/test-glitch")); + m.setEditMode(EditMode.ACCEPT_EDITS); + m.setLifecycle(SessionLifecycle.READY); + setModelComboPopulated(c, true); + + c.pollScreenState(); + assertEquals(EditMode.ACCEPT_EDITS, m.getEditMode(), "baseline poll must keep acceptEdits"); + + // Single glitch poll: screen briefly shows an unrelated mode (mid-redraw reflow) + screenRef.set(glitchScreen); + c.pollScreenState(); + assertEquals(EditMode.ACCEPT_EDITS, m.getEditMode(), + "a single glitch poll reading a different mode must not flip the model"); + + // Glitch was one-off; real footer reverts on the next poll + screenRef.set(acceptEditsScreen); + c.pollScreenState(); + assertEquals(EditMode.ACCEPT_EDITS, m.getEditMode(), + "model must still show acceptEdits once the glitch frame is gone"); + } + + @Test + void pollScreenStateEditModeAppliesAfterTwoConsecutiveConfirmations() throws Exception { + List acceptEditsScreen = Collections.singletonList( + "⏵⏵ accept edits on (shift+tab to cycle)"); + List autoScreen = Collections.singletonList( + "⏵⏵ auto mode on (shift+tab to cycle)"); + AtomicReference> screenRef = new AtomicReference<>(acceptEditsScreen); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, screenRef::get); + m.setWorkingDirectory(new java.io.File("/tmp/test-confirm")); + m.setEditMode(EditMode.ACCEPT_EDITS); + m.setLifecycle(SessionLifecycle.READY); + setModelComboPopulated(c, true); + + c.pollScreenState(); + assertEquals(EditMode.ACCEPT_EDITS, m.getEditMode()); + + screenRef.set(autoScreen); + c.pollScreenState(); + assertEquals(EditMode.ACCEPT_EDITS, m.getEditMode(), + "first differing poll must only become a pending candidate, not apply immediately"); + + c.pollScreenState(); + assertEquals(EditMode.AUTO, m.getEditMode(), + "second consecutive confirming poll must apply the new mode"); + } + + private static void setModelComboPopulated(ClaudeSessionController c, boolean value) throws Exception { + java.lang.reflect.Field f = ClaudeSessionController.class.getDeclaredField("modelComboPopulated"); + f.setAccessible(true); + f.setBoolean(c, value); } // ------------------------------------------------------------------------- @@ -857,6 +937,64 @@ private static boolean invokeTrySend(ClaudeSessionController c, EditMode target) return (boolean) m.invoke(c, target); } + // ------------------------------------------------------------------------- + // waitForConcreteModeChange — glitch-frame debounce + // + // Regression: a single screen-poll tick can catch Claude's Ink renderer + // mid-repaint of the footer and read an unrelated, one-off mode. Returning + // that immediately would desynchronize trySendShiftTabsUntilMode's notion of + // "current mode" from what CC actually shows, causing the final mode to land + // somewhere other than the one requested. A differing mode is only reported + // once the same mode is seen on two consecutive polls. + // ------------------------------------------------------------------------- + + @Test + void waitForConcreteModeChange_singleGlitchPollIsNotReportedAsTheChange() throws Exception { + // poll-1: one-off glitch shows AUTO; poll-2+: the real, stable new mode (ACCEPT_EDITS). + AtomicReference poll = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { + int p = poll.updateAndGet(v -> v + 1); + return p == 1 ? Optional.of(EditMode.AUTO) : Optional.of(EditMode.ACCEPT_EDITS); + } + }; + setModeSwitchPollMs(c, 1); + + Optional result = invokeWaitForConcreteModeChange(c, Optional.empty()); + + assertEquals(Optional.of(EditMode.ACCEPT_EDITS), result, + "must resolve to the mode confirmed on two consecutive polls, not the one-off glitch"); + } + + @Test + void waitForConcreteModeChange_confirmsOnSecondConsecutiveAgreeingPoll() throws Exception { + // No glitch: the same new mode appears on both poll-1 and poll-2. + AtomicReference poll = new AtomicReference<>(0); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, Collections::emptyList) { + @Override Optional detectCurrentMode() { + poll.updateAndGet(v -> v + 1); + return Optional.of(EditMode.ACCEPT_EDITS); + } + }; + setModeSwitchPollMs(c, 1); + + Optional result = invokeWaitForConcreteModeChange(c, Optional.empty()); + + assertEquals(Optional.of(EditMode.ACCEPT_EDITS), result); + assertEquals(2, (int) poll.get(), "must confirm on exactly the second consecutive agreeing poll"); + } + + @SuppressWarnings("unchecked") + private static Optional invokeWaitForConcreteModeChange( + ClaudeSessionController c, Optional before) throws Exception { + java.lang.reflect.Method m = ClaudeSessionController.class + .getDeclaredMethod("waitForConcreteModeChange", Optional.class); + m.setAccessible(true); + return (Optional) m.invoke(c, before); + } + // ------------------------------------------------------------------------- // sendShiftTabsUntilMode — empty detectEditMode must be treated as "default" // -------------------------------------------------------------------------