diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65b97bb..071057f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Added support for long choice dialogs (e.g. /model with more than 9 items): all items are listed with a scrollbar when the list is taller than the new "Choice menu max height" setting (Tools → Options → Claude Code → General, default 320 px), items 10 and above can be selected, and the model list at startup and the model combo include every model
- Fixed the choice dialog (e.g. /model) endlessly flickering and resizing when not all menu items fit on screen
# 1.3.20 (2026-10-04)
diff --git a/claude-launch-tests/test_long_choice_menu.py b/claude-launch-tests/test_long_choice_menu.py
new file mode 100644
index 0000000..536900f
--- /dev/null
+++ b/claude-launch-tests/test_long_choice_menu.py
@@ -0,0 +1,98 @@
+"""
+Hypotheses for long (scrolling) choice menus, using /model in a small terminal:
+ A. Down/Up arrows move the cursor and scroll the window.
+ B. Down on the last item wraps to the first one (cyclic navigation).
+ C. (observed manually) a digit key selects its item immediately -> two-digit numbers cannot be typed.
+ D. Arrow navigation + Enter selects the intended item (checked via cursor number only, Esc to cancel).
+
+Screens are rendered with pexpect's ANSI emulator and dumped.
+"""
+import os
+import re
+import sys
+import time
+
+import pexpect
+import pyte
+
+CLAUDE = os.environ.get('CLAUDE_BIN', '/usr/local/bin/claude')
+ROWS, COLS = 16, 120
+DOWN, UP, ESC = '\x1b[B', '\x1b[A', '\x1b'
+CURSOR = re.compile(r'❯\s*(\d+)\.')
+
+
+class Term:
+ def __init__(self):
+ self.screen = pyte.Screen(COLS, ROWS)
+ self.stream = pyte.Stream(self.screen)
+
+
+def pump(child, term, secs=0.6):
+ end = time.time() + secs
+ while time.time() < end:
+ try:
+ term.stream.feed(child.read_nonblocking(65536, timeout=0.1))
+ except pexpect.TIMEOUT:
+ pass
+
+
+def lines(term):
+ return [l.rstrip() for l in term.screen.display]
+
+
+def cursor_num(screen):
+ for l in lines(screen):
+ m = CURSOR.search(l)
+ if m:
+ return int(m.group(1))
+ return None
+
+
+def dump(title, screen):
+ print(f'--- {title} ---')
+ for l in lines(screen):
+ if l.strip():
+ print(l)
+
+
+def main():
+ child = pexpect.spawn(CLAUDE, cwd='/tmp', dimensions=(ROWS, COLS), encoding='utf-8', codec_errors='replace',
+ env=dict(os.environ, TERM='xterm-256color'))
+ screen = Term()
+ pump(child, screen, 12)
+ time.sleep(1.5)
+ child.send('/model'); pump(child, screen, 0.3)
+ child.send(ESC); pump(child, screen, 0.3)
+ child.send('\r'); pump(child, screen, 1.5)
+ dump('menu opened', screen)
+ start = cursor_num(screen)
+ ok = True
+
+ # A: scrolling downward
+ seen = [start]
+ for _ in range(14):
+ child.send(DOWN); pump(child, screen, 0.25)
+ seen.append(cursor_num(screen))
+ dump('after 14 Down', screen)
+ print('cursor sequence on Down:', seen)
+
+ # B: wrap-around is visible in the sequence (max -> 1)
+ nums = [n for n in seen if n]
+ wrapped = any(b < a for a, b in zip(nums, nums[1:]))
+ print('B wrap-around on Down:', wrapped)
+
+ # D: Up navigation
+ child.send(UP); pump(child, screen, 0.3)
+ print('cursor after one Up:', cursor_num(screen))
+
+ # C (not automated): a digit selects immediately and SAVES the model as default,
+ # so '10' cannot be typed - observed manually: '1' applied item 1 at once.
+
+ child.send(ESC); pump(child, screen, 0.5)
+ child.close(force=True)
+ print('RESULT: scrolling=%s wrap=%s' % (len(set(nums)) > 1, wrapped))
+ return 0 if len(set(nums)) > 1 else 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/docs/screenshots/settings-general.png b/docs/screenshots/settings-general.png
index 3e47a68..a298f87 100644
Binary files a/docs/screenshots/settings-general.png and b/docs/screenshots/settings-general.png differ
diff --git a/docs/user-manual.md b/docs/user-manual.md
index 292dcb3..330ba37 100644
--- a/docs/user-manual.md
+++ b/docs/user-manual.md
@@ -272,6 +272,8 @@ Claude Code sometimes presents interactive prompts (Yes/No or multiple choice).
**Other options** appear as radio buttons. Some options may include a text field for additional input. Click the desired option (and optionally fill in the text field), then press **Enter** or click **Send** to submit. Press **Escape** to cancel.
+**Long menus.** When Claude shows only part of a long list (e.g. `/model` with more than a handful of models), the plugin scrolls through the whole list once to collect every item, then shows them all. If the list is taller than **Choice menu max height** (see [Settings](#11-settings)), the option list gets a scrollbar. Items numbered 10 and above are selected by moving Claude's cursor with the arrow keys, so every item can be chosen.
+
After submitting or cancelling, the input area is restored automatically.
**Note:** If the choice panel does not appear when Claude asks a question, or appears unexpectedly without a question — click into the terminal area and interact with Claude directly. The panel will disappear automatically once Claude's state changes.
@@ -383,6 +385,7 @@ Open **Tools → Options → Claude Code** in NetBeans.
| Automatically open Plan Preview tab when Claude writes a plan | On | When enabled, a live Markdown Preview tab opens automatically as soon as the user clicks Allow in a diff panel for a file in a `plans/` directory. |
| Markdown preview dock position | Right side | Where the Markdown Preview tab is docked when first opened. Choices: Editor area, Right side, Left side top, Left side bottom, Bottom dock. |
| Choice menu focus | Grab focus | Controls whether the choice menu panel grabs keyboard focus when it appears. Choices: Grab focus, Show without grabbing focus, Hide menu. |
+| Choice menu max height (px) | 320 | Maximum height of the option list in the choice menu panel (100–2000). Longer lists (e.g. `/model` with many models) get a scrollbar; the scrollbar also appears or disappears by itself when you resize the panel by dragging the divider. |
| Terminal font | Auto | Font used in the embedded terminal. Click **Choose…** to open the font picker. The label shows the resolved font name (e.g. `Adwaita Mono, 14 (Auto)` when auto-detection is active, or `Monospaced, 14` when set explicitly). |
#### Terminal font picker
diff --git a/pom.xml b/pom.xml
index 63d2ccb..887e825 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbpluginsnetbeans-plugin-claude-code-gui
- 1.3.39-SNAPSHOT
+ 1.3.42-SNAPSHOTnbmNetbeans Plugin Claude Code GUINetBeans 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 ae40e52..ce072f9 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java
@@ -11,6 +11,7 @@
import io.github.nbplugins.claudecodegui.model.SessionMode;
import io.github.nbplugins.claudecodegui.process.ClaudeProcess;
import io.github.nbplugins.claudecodegui.process.ClaudeSessionStore;
+import io.github.nbplugins.claudecodegui.process.MenuEnumerator;
import io.github.nbplugins.claudecodegui.process.ModelMenuParser;
import io.github.nbplugins.claudecodegui.process.PtyTtyConnector;
import io.github.nbplugins.claudecodegui.process.ScreenContentDetector;
@@ -61,6 +62,10 @@ public class ClaudeSessionController {
/** DEL character (0x7F) — acts as BackSpace in xterm-256color terminals. */
private static final byte[] PTY_BACKSPACE = {0x7f};
+ private static final byte[] PTY_ARROW_UP = {0x1b, '[', 'A'};
+ private static final byte[] PTY_ARROW_DOWN = {0x1b, '[', 'B'};
+ /** Pause after an arrow key before the screen is read while enumerating a scrolling menu. */
+ private static final long MENU_ENUMERATION_SETTLE_MS = 120;
private static final int MAX_MODEL_DISCOVERY_ATTEMPTS = 1;
@@ -124,6 +129,8 @@ public class ClaudeSessionController {
/** {@code true} while the model-discovery or model-switch background thread is running. */
private volatile boolean modelDiscoveryInProgress = false;
+ /** True while {@link MenuEnumerator} walks the cursor through a scrolling menu. */
+ private volatile boolean menuEnumerationInProgress = false;
/** Number of model-discovery attempts made since the last session start. */
private int modelDiscoveryAttempts = 0;
@@ -645,6 +652,13 @@ public void writePtyAnswer(String answer) {
t.setDaemon(true);
t.start();
}
+ } else if (answer.matches("[0-9]{2,}") && menuCursorNumber(current) > 0) {
+ // Items 10+ cannot be typed (a digit selects immediately): move the PTY
+ // cursor onto the item with arrow keys, then Enter.
+ int delta = Integer.parseInt(answer) - menuCursorNumber(current);
+ LOG.fine("[PTY write] ARROW-number answer=" + answer + " delta=" + delta);
+ clearActiveChoiceMenu();
+ sendArrowsThenEnter(delta, "pty-arrow-number");
} else {
boolean isMenuDigit = answer.matches("[0-9]");
String toWrite = isMenuDigit ? answer : answer + "\r";
@@ -658,6 +672,33 @@ public void writePtyAnswer(String answer) {
}
}
+ /** Menu number under the PTY cursor according to {@code menu}, or 0 if unknown. */
+ private int menuCursorNumber(ChoiceMenuModel menu) {
+ if (menu != null && menu.cursorNumber() > 0) return menu.cursorNumber();
+ ChoiceMenuModel active = model.getActiveChoiceMenu();
+ return active != null ? active.cursorNumber() : 0;
+ }
+
+ /** Presses Down (delta > 0) or Up (delta < 0) {@code |delta|} times, then Enter, on a daemon thread. */
+ private void sendArrowsThenEnter(int delta, String threadName) {
+ Thread t = new Thread(() -> {
+ try {
+ byte[] arrow = delta < 0 ? PTY_ARROW_UP : PTY_ARROW_DOWN;
+ for (int i = 0; i < Math.abs(delta); i++) {
+ connector.write(arrow);
+ Thread.sleep(80);
+ }
+ connector.write("\r");
+ } catch (IOException ex) {
+ LOG.warning(threadName + " write failed: " + ex.getMessage());
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+ }, threadName);
+ t.setDaemon(true);
+ t.start();
+ }
+
/**
* Computes which checkbox option responses must be toggled in the PTY to reach
* {@code desired} from the current states in {@code currentOptions}.
@@ -910,7 +951,24 @@ public void switchModel(int index) {
Thread t = new Thread(() -> {
try {
openModelMenu();
- connector.write(String.valueOf(index + 1));
+ if (index < 9) {
+ connector.write(String.valueOf(index + 1));
+ } else {
+ // Items 10+ cannot be typed: move the cursor onto the item, then Enter.
+ int cursor = waitForModelMenuCursor();
+ if (cursor > 0) {
+ int delta = index + 1 - cursor;
+ byte[] arrow = delta < 0 ? PTY_ARROW_UP : PTY_ARROW_DOWN;
+ for (int i = 0; i < Math.abs(delta); i++) {
+ connector.write(arrow);
+ Thread.sleep(80);
+ }
+ connector.write("\r");
+ } else {
+ LOG.warning("model switch: /model menu not visible, cannot select item " + (index + 1));
+ connector.write(PTY_ESC);
+ }
+ }
Thread.sleep(500);
} catch (IOException | InterruptedException ex) {
LOG.warning("model switch failed: " + ex.getMessage());
@@ -928,6 +986,16 @@ public void switchModel(int index) {
// Model discovery
// -------------------------------------------------------------------------
+ /** Waits (up to ~3 s) for the {@code /model} menu and returns the number under the cursor, or 0. */
+ private int waitForModelMenuCursor() throws InterruptedException {
+ for (int i = 0; i < 20; i++) {
+ Optional m = screenContentDetector.detectChoiceMenu(screenLines.get());
+ if (m.isPresent() && m.get().cursorNumber() > 0) return m.get().cursorNumber();
+ Thread.sleep(150);
+ }
+ return 0;
+ }
+
/**
* Opens the {@code /model} selection menu, parses the screen, and populates
* the model list in {@link ClaudeSessionModel}.
@@ -987,6 +1055,14 @@ void discoverModels() {
}
}
+ // A long menu shows only a window of the models: walk the cursor through the
+ // whole list (and back) so the combo gets every model, not just the visible ones.
+ if (menuOpt.isPresent() && menuOpt.get().scrollable()) {
+ ChoiceMenuModel full = newMenuEnumerator().enumerate(menuOpt.get());
+ menuOpt = Optional.of(full);
+ lines = MenuEnumerator.toScreenLines(full);
+ }
+
// Scan the rolling PTY line buffer for the /model hint line, which is
// shorter and never truncated (e.g. "/model … (currently anthropic/claude-sonnet-4.6)").
String hintCurrentModel = null;
@@ -1134,7 +1210,7 @@ void pollScreenState() {
// (e.g. thinking spinner keeps PTY active and prevents the 400 ms silence).
// Also clear stale menu when it disappears from screen — flushPendingPrompt
// may never fire if the spinner continuously restarts promptFlushTimer.
- if (!modelDiscoveryInProgress) {
+ if (!modelDiscoveryInProgress && !menuEnumerationInProgress) {
Optional menuOpt = screenContentDetector.detectChoiceMenu(lines);
if (menuOpt.isPresent()) {
applyDetectedChoiceMenu(menuOpt.get(), "[pollScreenState] ");
@@ -1274,7 +1350,7 @@ private void detectAndApplyInitialEditMode(List lines) {
*/
void flushPendingPrompt() {
LOG.fine("[flushPendingPrompt] enter, modelDiscoveryInProgress=" + modelDiscoveryInProgress);
- if (modelDiscoveryInProgress) return;
+ if (modelDiscoveryInProgress || menuEnumerationInProgress) return;
Optional req = Optional.empty();
List lines = screenLines.get();
@@ -1335,7 +1411,7 @@ void flushPendingPrompt() {
req.ifPresent(r -> {
LOG.fine("[screen prompt flush] setting choice menu: \"" + r.text() + "\" | options=" + r.options());
- model.setActiveChoiceMenu(r);
+ showChoiceMenu(r);
});
}
@@ -1430,12 +1506,51 @@ private void applyDetectedEditMode(Optional detected) {
}
}
+ /**
+ * Shows {@code menu} in the choice panel. A scrolling menu (only a window of a longer
+ * list is on screen) is first completed on a daemon thread by {@link MenuEnumerator},
+ * which walks the PTY cursor through the whole list and back; the poll timers are
+ * suppressed meanwhile, and the panel is shown only once the full list is known.
+ */
+ private void showChoiceMenu(ChoiceMenuModel menu) {
+ if (!menu.scrollable() || connector == null || menuEnumerationInProgress) {
+ model.setActiveChoiceMenu(menu);
+ return;
+ }
+ menuEnumerationInProgress = true;
+ Thread t = new Thread(() -> {
+ ChoiceMenuModel full = menu;
+ try {
+ full = newMenuEnumerator().enumerate(menu);
+ } catch (IOException ex) {
+ LOG.warning("menu enumeration failed: " + ex.getMessage());
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ } finally {
+ final ChoiceMenuModel result = full;
+ SwingUtilities.invokeLater(() -> {
+ menuEnumerationInProgress = false;
+ if (model.getActiveChoiceMenu() == null) model.setActiveChoiceMenu(result);
+ });
+ }
+ }, "claude-menu-enumeration");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ private MenuEnumerator newMenuEnumerator() {
+ return new MenuEnumerator(screenContentDetector, screenLines, new MenuEnumerator.Keys() {
+ @Override public void down() throws IOException { connector.write(PTY_ARROW_DOWN); }
+ @Override public void up() throws IOException { connector.write(PTY_ARROW_UP); }
+ }, Thread::sleep, MENU_ENUMERATION_SETTLE_MS);
+ }
+
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);
+ showChoiceMenu(newMenu);
return;
}
if (sameMenu(newMenu, current) || isShrunkViewOf(newMenu, current)) {
diff --git a/src/main/java/io/github/nbplugins/claudecodegui/model/ChoiceMenuModel.java b/src/main/java/io/github/nbplugins/claudecodegui/model/ChoiceMenuModel.java
index 9351f5c..1ec544c 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/model/ChoiceMenuModel.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/model/ChoiceMenuModel.java
@@ -102,6 +102,12 @@ public Option(String display, String response, String description, boolean check
private final List