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