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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
98 changes: 98 additions & 0 deletions claude-launch-tests/test_long_choice_menu.py
Original file line number Diff line number Diff line change
@@ -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())
Binary file modified docs/screenshots/settings-general.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/user-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
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.39-SNAPSHOT</version>
<version>1.3.42-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 @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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 &gt; 0) or Up (delta &lt; 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}.
Expand Down Expand Up @@ -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());
Expand All @@ -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<ChoiceMenuModel> 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}.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ChoiceMenuModel> menuOpt = screenContentDetector.detectChoiceMenu(lines);
if (menuOpt.isPresent()) {
applyDetectedChoiceMenu(menuOpt.get(), "[pollScreenState] ");
Expand Down Expand Up @@ -1274,7 +1350,7 @@ private void detectAndApplyInitialEditMode(List<String> lines) {
*/
void flushPendingPrompt() {
LOG.fine("[flushPendingPrompt] enter, modelDiscoveryInProgress=" + modelDiscoveryInProgress);
if (modelDiscoveryInProgress) return;
if (modelDiscoveryInProgress || menuEnumerationInProgress) return;

Optional<ChoiceMenuModel> req = Optional.empty();
List<String> lines = screenLines.get();
Expand Down Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -1430,12 +1506,51 @@ private void applyDetectedEditMode(Optional<EditMode> 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)) {
Expand Down
Loading
Loading