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.nbplugins netbeans-plugin-claude-code-gui - 1.3.39-SNAPSHOT + 1.3.42-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 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