Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
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.31-SNAPSHOT</version>
<version>1.3.32-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 @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -321,7 +328,7 @@ public void stopProcess() {
firstOutputReceived = false;

model.setModelList(List.of(), -1);
model.clearChoiceMenu();
clearActiveChoiceMenu();
model.clearEditModeRegistry();
model.setLifecycle(SessionLifecycle.STARTING);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -1110,12 +1117,7 @@ void pollScreenState() {
if (!modelDiscoveryInProgress) {
Optional<ChoiceMenuModel> 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
Expand All @@ -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();
}
}
}
Expand Down Expand Up @@ -1223,7 +1225,7 @@ private void killProcessForErrorPanel() {
firstOutputReceived = false;

model.setModelList(List.of(), -1);
model.clearChoiceMenu();
clearActiveChoiceMenu();
model.clearEditModeRegistry();
model.setLifecycle(SessionLifecycle.STARTING);
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 <em>different</em>
* menu is already active — defers applying it until the same replacement candidate
* is confirmed by a second consecutive detection.
*
* <p>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<ChoiceMenuModel.Option> a, List<ChoiceMenuModel.Option> b) {
if (a.size() != b.size()) return false;
if (!a.isEmpty() && a.get(0).response().startsWith("ARROW:")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>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.
*
* <p>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<String> 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 <noreply@anthropic.com>",
" 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<String> 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 <noreply@anthropic.com>",
" 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<List<String>> 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<String> menuA = Arrays.asList(
" Do you want to proceed?",
" ❯ 1. Yes",
" 2. No",
" Esc to cancel"
);
List<String> menuB = Arrays.asList(
" Claude wants to exit plan mode",
" ❯ 1. Yes",
" 2. No",
" Esc to cancel"
);
AtomicReference<List<String>> 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
// -------------------------------------------------------------------------
Expand Down
Loading