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"
// -------------------------------------------------------------------------