From c909b58cff11babba4e3ef254f4088aa44fc2758 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 28 Sep 2026 21:47:46 +0300 Subject: [PATCH] Fixed a choice-dialog prompt flickering and repeatedly resizing when Claude's response text re-wrapped to a slightly different width after the dialog first appeared Showing a choice menu resizes the terminal to fit ChoiceMenuPanel, which makes Claude's Ink renderer re-wrap the dialog at the new column width. The reflowed variant was previously applied to the model immediately, triggering another resize, another reflow, and so on. detectChoiceMenu now requires a replacement candidate to be confirmed on two consecutive polls before it replaces the currently active menu, so a one-off reflow never causes the destabilizing resize that produced it. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 + pom.xml | 2 +- .../controller/ClaudeSessionController.java | 98 ++++++++++---- .../ClaudeSessionControllerTest.java | 120 ++++++++++++++++++ 4 files changed, 196 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e09a07b..8f05c324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- Fixed a choice-dialog prompt (e.g. a Bash-command permission prompt) flickering and repeatedly resizing when Claude's response text re-wrapped to a slightly different width after the dialog first appeared + # 1.3.12 (2026-09-27) - Fixed the plugin's Auto Mode indicator incorrectly turning on when a permission prompt (e.g. for a Bash command) merely mentioned "auto mode" in its tip text or "switch to auto mode" menu option, instead of the mode actually changing diff --git a/pom.xml b/pom.xml index 4d50c1e7..8ecbe430 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbplugins netbeans-plugin-claude-code-gui - 1.3.31-SNAPSHOT + 1.3.32-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 5f027573..9b9ee8dc 100644 --- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java @@ -134,6 +134,13 @@ public class ClaudeSessionController { /** Number of standard (CC-discovered) models; used to distinguish custom-model indices in {@link #switchModel}. */ private int standardModelCount = 0; + /** + * A newly detected choice menu that differs from the currently active one, held + * pending a second consecutive confirming detection before it replaces the active + * menu. See {@link #applyDetectedChoiceMenu}. + */ + private ChoiceMenuModel pendingChoiceMenuCandidate; + /** * 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 @@ -259,7 +266,7 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr modelDiscoveryAttempts = 0; modelComboPopulated = false; - model.clearChoiceMenu(); + clearActiveChoiceMenu(); model.setLifecycle(SessionLifecycle.STARTING); model.setWorkingDirectory(dir); model.loadPersistedHistory(); @@ -321,7 +328,7 @@ public void stopProcess() { firstOutputReceived = false; model.setModelList(List.of(), -1); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); model.clearEditModeRegistry(); model.setLifecycle(SessionLifecycle.STARTING); } @@ -469,7 +476,7 @@ public void writePtyAnswer(String answer) { }, "pty-multiselect"); t.setDaemon(true); t.start(); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); } else if (answer.startsWith("MULTI_TYPE:")) { // Format: MULTI_TYPE:checks:typeN:text // e.g. MULTI_TYPE:1,3:5:my text or MULTI_TYPE::5:my text @@ -525,7 +532,7 @@ public void writePtyAnswer(String answer) { }, "pty-multitype"); t.setDaemon(true); t.start(); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); } else if (answer.startsWith("TYPE:")) { int sep = answer.indexOf(':', 5); String digit = answer.substring(5, sep); @@ -569,14 +576,14 @@ public void writePtyAnswer(String answer) { }, "pty-typeinput"); t.setDaemon(true); t.start(); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); } else if (answer.startsWith("ARROW:")) { int targetIdx = Integer.parseInt(answer.substring(6)); ChoiceMenuModel current = model.getActiveChoiceMenu(); int currentIdx = current != null ? current.defaultOptionIndex() : 0; int delta = targetIdx - currentIdx; LOG.fine("[PTY write] ARROW targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); if (delta == 0) { connector.write("\r"); } else { @@ -610,7 +617,7 @@ public void writePtyAnswer(String answer) { int currentIdx = currentIdx2; int delta = targetIdx - currentIdx; LOG.fine("[PTY write] ARROW-digit targetIdx=" + targetIdx + " currentIdx=" + currentIdx + " delta=" + delta); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); if (delta == 0) { connector.write("\r"); } else { @@ -636,7 +643,7 @@ public void writePtyAnswer(String answer) { String toWrite = isMenuDigit ? answer : answer + "\r"; LOG.fine("[PTY write] " + toWrite.replace("\r", "\\r").replace("\n", "\\n")); connector.write(toWrite); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); } } } catch (IOException ex) { @@ -1110,12 +1117,7 @@ void pollScreenState() { if (!modelDiscoveryInProgress) { Optional menuOpt = screenContentDetector.detectChoiceMenu(lines); if (menuOpt.isPresent()) { - ChoiceMenuModel newMenu = menuOpt.get(); - ChoiceMenuModel current = model.getActiveChoiceMenu(); - if (current == null || !newMenu.text().equals(current.text()) || !optionsEqual(newMenu.options(), current.options())) { - LOG.fine("[pollScreenState] setting choice menu: \"" + newMenu.text() + "\""); - model.setActiveChoiceMenu(newMenu); - } + applyDetectedChoiceMenu(menuOpt.get(), "[pollScreenState] "); } else if (model.getActiveChoiceMenu() != null && !screenContentDetector.detectYesNoPrompt(lines)) { // For unnumbered menus (ARROW: responses), don't dismiss from the screen-poll timer. // The /resume picker redraws itself during PTY resize (when the choice panel @@ -1127,7 +1129,7 @@ void pollScreenState() { && active.options().get(0).response().startsWith("ARROW:"); if (!isUnnumbered) { LOG.fine("[pollScreenState] menu gone from screen, dismissing"); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); } } } @@ -1223,7 +1225,7 @@ private void killProcessForErrorPanel() { firstOutputReceived = false; model.setModelList(List.of(), -1); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); model.clearEditModeRegistry(); model.setLifecycle(SessionLifecycle.STARTING); } @@ -1301,16 +1303,9 @@ void flushPendingPrompt() { return; } LOG.fine("[screen prompt] menu gone from screen, dismissing"); - model.clearChoiceMenu(); + clearActiveChoiceMenu(); } else { - ChoiceMenuModel newMenu = req.get(); - ChoiceMenuModel current = model.getActiveChoiceMenu(); - if (!newMenu.text().equals(current.text()) || !optionsEqual(newMenu.options(), current.options())) { - LOG.fine("[screen prompt] menu changed, updating (req=" + newMenu.text() + ")"); - model.setActiveChoiceMenu(newMenu); - } else { - LOG.fine("[screen prompt] menu still on screen, keeping (req=" + newMenu.text() + ")"); - } + applyDetectedChoiceMenu(req.get(), "[screen prompt] "); } return; } @@ -1363,6 +1358,59 @@ private ChoiceMenuModel detectCurrentMenu() { return screenContentDetector.detectChoiceMenu(lines).orElse(model.getActiveChoiceMenu()); } + /** + * Applies a newly detected choice menu to the model, or — if a different + * menu is already active — defers applying it until the same replacement candidate + * is confirmed by a second consecutive detection. + * + *

Showing a choice menu resizes the terminal to fit {@code ChoiceMenuPanel} + * (see {@code ClaudeSessionTab#switchSouthCard}), which makes Claude's Ink renderer + * re-wrap the same dialog at the new column width. Without this debounce, that + * transiently different-looking reflow would immediately replace the menu just + * shown, triggering another divider resize, another reflow, and so on — a visible + * flicker loop. Withholding the replacement until it is confirmed twice means the + * destabilizing resize never happens for a one-off reflow artifact, so the screen + * naturally reverts to matching the currently active menu on the next poll. + * + * @param newMenu the menu detected on this poll + * @param logTag short prefix identifying the calling poll path, for LOG.fine messages + */ + private void applyDetectedChoiceMenu(ChoiceMenuModel newMenu, String logTag) { + ChoiceMenuModel current = model.getActiveChoiceMenu(); + if (current == null) { + pendingChoiceMenuCandidate = null; + LOG.fine(logTag + "setting choice menu: \"" + newMenu.text() + "\""); + model.setActiveChoiceMenu(newMenu); + return; + } + if (sameMenu(newMenu, current)) { + pendingChoiceMenuCandidate = null; + return; + } + if (pendingChoiceMenuCandidate != null && sameMenu(newMenu, pendingChoiceMenuCandidate)) { + LOG.fine(logTag + "confirmed replacement menu: \"" + newMenu.text() + "\""); + pendingChoiceMenuCandidate = null; + model.setActiveChoiceMenu(newMenu); + } else { + LOG.fine(logTag + "candidate replacement menu, awaiting confirmation: \"" + newMenu.text() + "\""); + pendingChoiceMenuCandidate = newMenu; + } + } + + private static boolean sameMenu(ChoiceMenuModel a, ChoiceMenuModel b) { + return a.text().equals(b.text()) && optionsEqual(a.options(), b.options()); + } + + /** + * Clears the active choice menu and any pending replacement candidate held by + * {@link #applyDetectedChoiceMenu}, so a stale candidate from before the menu was + * dismissed can never leak into whatever is shown next. + */ + private void clearActiveChoiceMenu() { + pendingChoiceMenuCandidate = null; + model.clearChoiceMenu(); + } + private static boolean optionsEqual(List a, List b) { if (a.size() != b.size()) return false; if (!a.isEmpty() && a.get(0).response().startsWith("ARROW:")) { 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 78dbcb21..f32ecc24 100644 --- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java +++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java @@ -354,6 +354,126 @@ void pollScreenStateSetsNewMenuAfterStaleMenuCleared() throws Exception { assertNotNull(m.getActiveChoiceMenu(), "new menu must be detected after stale cleared"); } + // ------------------------------------------------------------------------- + // pollScreenState — flicker regression: choice-panel resize reflows the same + // dialog to a different wrapped width, which must not immediately replace it + // ------------------------------------------------------------------------- + + /** + * Regression (GrandOrgue project bug report): showing a choice menu resizes the + * terminal (to fit the ChoiceMenuPanel), which makes Claude's Ink renderer re-wrap + * the SAME dialog at the new column width. If the reflowed variant is applied to + * the model immediately, it triggers another divider resize, causing another + * reflow, looping forever (visible as a flickering dialog). + * + *

These are the actual "git-commit-bash-dialog" / "git-commit-bash-dialog-narrow" + * fixtures from {@code screen-content-detector/detect-choice-menu/} — the same + * real-world dialog, reflowed at two different terminal widths; only option 2's + * wrapped display text differs. + * + *

Fix: a replacement candidate that differs from the currently active menu must + * be seen on two consecutive polls before it replaces the active menu. + */ + @Test + void pollScreenStateDoesNotFlickerBetweenReflowedMenuVariants() throws Exception { + List normalWidth = Arrays.asList( + " Bash command", + "", + " git add pom.xml README.md && git commit -m \"$(cat <<'EOF'", + " Rename Maven project: name to \"Netbeans Claude Code GUI\", artifactId to \"netbeans-claude-code-gui\" (0.14.17-SNAPSHOT)", + "", + " Co-Authored-By: Claude Sonnet 4.6 ", + " EOF", + " )\"", + " Run shell command", + "", + " Do you want to proceed?", + " ❯ 1. Yes", + " 2. Yes, and don't ask again for git add and git commit commands in /home/oleg/my-projects/NetbeansClaudeCodePlugin", + " 3. No", + "", + " Esc to cancel · Tab to amend · ctrl+e to explain" + ); + List narrowWidth = Arrays.asList( + " Bash command", + "", + " git add pom.xml README.md && git commit -m \"$(cat <<'EOF'", + " Rename Maven project: name to \"Netbeans Claude Code GUI\", artifactId to \"netbeans-claude-code-gui\"", + " (0.14.17-SNAPSHOT)", + "", + " Co-Authored-By: Claude Sonnet 4.6 ", + " EOF", + " )\"", + " Run shell command", + "", + " Do you want to proceed?", + " ❯ 1. Yes", + " 2. Yes, and don't ask again for git add and git commit commands in /home/oleg/my-projects/Ne", + "tbeansClaudeCodePlugin", + " 3. No", + "", + " Esc to cancel · Tab to amend · ctrl+e to explain" + ); + + AtomicReference> screenRef = new AtomicReference<>(normalWidth); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, screenRef::get); + + c.pollScreenState(); + ChoiceMenuModel shown = m.getActiveChoiceMenu(); + assertNotNull(shown, "menu must be set on first poll"); + String originalOption2 = shown.options().get(1).display(); + + // Choice panel resize reflows the dialog to a narrower width for one poll. + screenRef.set(narrowWidth); + c.pollScreenState(); + assertEquals(originalOption2, m.getActiveChoiceMenu().options().get(1).display(), + "a single differing poll must not replace the active menu with the reflowed variant"); + + // Screen reflows back to the original width before the narrow variant is ever + // confirmed twice in a row -- the menu must never have flickered to it. + screenRef.set(normalWidth); + c.pollScreenState(); + assertEquals(originalOption2, m.getActiveChoiceMenu().options().get(1).display(), + "menu must remain stable and never show the transient reflowed variant"); + } + + /** + * A genuinely different menu (not a reflow artifact) must still be applied once + * it is seen on two consecutive polls, so the debounce added for the flicker fix + * does not permanently block legitimate menu replacements. + */ + @Test + void pollScreenStateAppliesNewMenuAfterTwoConsecutiveConfirmations() throws Exception { + List menuA = Arrays.asList( + " Do you want to proceed?", + " ❯ 1. Yes", + " 2. No", + " Esc to cancel" + ); + List menuB = Arrays.asList( + " Claude wants to exit plan mode", + " ❯ 1. Yes", + " 2. No", + " Esc to cancel" + ); + AtomicReference> screenRef = new AtomicReference<>(menuA); + ClaudeSessionModel m = new ClaudeSessionModel(); + ClaudeSessionController c = new ClaudeSessionController(m, screenRef::get); + + c.pollScreenState(); + assertEquals("Do you want to proceed?", m.getActiveChoiceMenu().text()); + + screenRef.set(menuB); + c.pollScreenState(); + assertEquals("Do you want to proceed?", m.getActiveChoiceMenu().text(), + "must not replace on the first differing detection"); + + c.pollScreenState(); + assertEquals("Claude wants to exit plan mode", m.getActiveChoiceMenu().text(), + "genuinely new menu must be applied after two consecutive confirmations"); + } + // ------------------------------------------------------------------------- // pollScreenState — model discovery deferred while screen shows WORKING // -------------------------------------------------------------------------