From f721941afad780b2b7220a148d9f0114fd1b8423 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 25 Apr 2026 09:18:07 +0300 Subject: [PATCH] Fixed choice menu not recognizing Ultraplan "Tell Claude what to change" as text input The Ultraplan plan-review menu option "Tell Claude what to change" was rendered as a plain radio button instead of a text input field. The "shift+tab to approve with this feedback" sub-line was incorrectly stored as the option's description rather than being treated as a hint that marks the option as hasTextInput. Fixes: - ScreenContentDetector: added "shift+tab" to isHintLine; when a "shift+tab to approve" line is detected below an option, sets hasTextInput=true on that option - ChoiceMenuModel.Option: added hasTextInput field (mirrors existing hasCheckbox pattern) - ChoiceMenuPanel.isTypeInputOption: also returns true for opt.hasTextInput() Python investigation confirmed that the existing TYPE:N:text mechanism (digit + text + \r) works correctly for this option. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + .../test_ultraplan_tell_claude.py | 131 ++++++++++++++++++ pom.xml | 2 +- .../model/ChoiceMenuModel.java | 43 ++++-- .../process/ScreenContentDetector.java | 16 ++- .../nbclaudecodegui/ui/ChoiceMenuPanel.java | 2 +- .../ultraplan-tell-claude.expectedRes.json | 10 ++ .../ultraplan-tell-claude.src.txt | 11 ++ 8 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 claude-launch-tests/test_ultraplan_tell_claude.py create mode 100644 src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.expectedRes.json create mode 100644 src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.src.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb1d7ae..3bba4971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Fixed choice menu not recognizing the Ultraplan "Tell Claude what to change" option as a text input field - Added "Bypass Permissions" edit mode: automatically detected when Claude is launched with `--dangerously-skip-permissions`; auto-allows all file edits without showing a diff dialog - Fixed PreToolUse and Stop hook HTTP 502 errors (regression in claude-code 2.1.118) - Fixed toolbar button focusing an existing session tab instead of opening a new one when all open tabs have active sessions diff --git a/claude-launch-tests/test_ultraplan_tell_claude.py b/claude-launch-tests/test_ultraplan_tell_claude.py new file mode 100644 index 00000000..59de67a8 --- /dev/null +++ b/claude-launch-tests/test_ultraplan_tell_claude.py @@ -0,0 +1,131 @@ +""" +Investigate: when Claude shows the Ultraplan "Would you like to proceed?" menu +with option 4 "Tell Claude what to change", what byte sequence correctly: + 1. Selects option 4 and activates text-entry mode + 2. Submits the typed text back to Claude + +Standard type-input menus use: digit → text → \r +But option 4 says "shift+tab to approve with this feedback", +suggesting shift+tab (\x1b[Z) might be the submit key. + +Test variants: + A. 4 → text → \r + B. 4 → text → shift+tab (\x1b[Z) +""" + +import pexpect +import re +import time +import sys +import os +import signal + +CLAUDE = '/usr/local/bin/claude' +CWD = '/tmp' + +SHIFT_TAB = '\x1b[Z' +ENTER = '\r' + +MARKER = 'XMARKERX42' # unique string to identify our feedback + + +def safe_buf(child): + before = child.before if isinstance(child.before, str) else '' + after = child.after if isinstance(child.after, str) else '' + buf = child.buffer if isinstance(child.buffer, str) else '' + return before + after + buf + + +def run_test(label, submit_key_name, submit_key, logpath, script_suffix): + print(f'\n=== {label} ===', flush=True) + logfile = open(logpath, 'w') + child = pexpect.spawn( + CLAUDE, + ['--permission-mode', 'plan', + f'Write a hello world Python script to /tmp/hello_plan_{script_suffix}.py'], + cwd=CWD, + encoding='utf-8', + timeout=120, + logfile=logfile, + ) + try: + # Wait for the plan-review menu + print(' Waiting for plan-review menu (up to 120s)...', flush=True) + child.expect(r'proceed\?', timeout=120) + print(' "proceed?" detected', flush=True) + + # Drain remaining menu output (wait for menu to finish drawing) + try: + child.expect(pexpect.TIMEOUT, timeout=2.0) + except pexpect.TIMEOUT: + pass + + buf_plain = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', safe_buf(child)) + has4 = 'Tell' in buf_plain or '4.' in buf_plain + print(f' Option 4 visible: {has4}', flush=True) + + # Step 1: Send "4" to select option 4 + print(f' Sending "4"...', flush=True) + child.send('4') + # Wait for Claude to respond (redraw menu or enter text mode) + try: + child.expect(pexpect.TIMEOUT, timeout=1.5) + except pexpect.TIMEOUT: + pass + buf_after4 = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', safe_buf(child)) + print(f' After "4" (plain): {repr(buf_after4[-200:])}', flush=True) + + # Step 2: Type the feedback + test_text = f'{MARKER} add error handling' + print(f' Typing: {repr(test_text)}', flush=True) + child.send(test_text) + time.sleep(0.3) + + # Step 3: Submit with tested key + print(f' Submitting with {submit_key_name}...', flush=True) + child.send(submit_key) + + # Wait for Claude to react — look for: + # - New ❯ prompt (main prompt, not menu cursor) + # - "proceed?" again (Claude re-planned with our feedback) + # - "written" or file path (Claude executed with our feedback) + # - timeout = no reaction + print(' Waiting for reaction (up to 60s)...', flush=True) + idx = child.expect( + [r'❯\xa0', r'proceed\?', r'written|Written|hello_plan', pexpect.TIMEOUT], + timeout=60 + ) + result_buf = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', safe_buf(child)) + if idx == 0: + print(f' RESULT: Main prompt appeared — Claude accepted feedback', flush=True) + elif idx == 1: + print(f' RESULT: New plan-review menu — Claude re-planned with feedback', flush=True) + elif idx == 2: + print(f' RESULT: Claude executed/wrote the script', flush=True) + else: + print(f' RESULT: TIMEOUT — no recognizable reaction', flush=True) + print(f' Final excerpt: {repr(result_buf[-300:])}', flush=True) + + except pexpect.TIMEOUT as e: + buf = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', child.buffer or '') + print(f' OUTER TIMEOUT. Buffer: {repr(buf[-400:])}', flush=True) + except pexpect.EOF: + print(' EOF', flush=True) + except Exception as e: + print(f' ERROR: {e}', flush=True) + finally: + try: + os.kill(child.pid, signal.SIGTERM) + except Exception: + pass + try: + child.close() + except Exception: + pass + logfile.close() + + +run_test('Test A: 4→text→\\r', 'enter', ENTER, '/tmp/pexpect_ultraplan_a.log', 'enter') +run_test('Test B: 4→text→shift+tab', 'shifttab', SHIFT_TAB, '/tmp/pexpect_ultraplan_b.log', 'shifttab') + +print('\nDone.', flush=True) diff --git a/pom.xml b/pom.xml index f928910a..33a66742 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.nbclaudecodegui netbeans-claude-code-gui - 0.22.22-SNAPSHOT + 0.22.23-SNAPSHOT nbm Netbeans 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/nbclaudecodegui/model/ChoiceMenuModel.java b/src/main/java/io/github/nbclaudecodegui/model/ChoiceMenuModel.java index 9957baab..c6e0868a 100644 --- a/src/main/java/io/github/nbclaudecodegui/model/ChoiceMenuModel.java +++ b/src/main/java/io/github/nbclaudecodegui/model/ChoiceMenuModel.java @@ -21,12 +21,15 @@ public final class ChoiceMenuModel { * below the label — present when Claude renders a description line under the option. * {@code checked} is set when the option has a checked checkbox marker ({@code [x]}). * {@code hasCheckbox} is set when the option has any checkbox marker ({@code [ ]} or {@code [x]}). + * {@code hasTextInput} is set when the option requires free-text entry, detected by a + * {@code "shift+tab to approve with this feedback"} hint line below the option. * - * @param display user-facing label shown in the menu - * @param response string sent back to Claude when this option is selected - * @param description optional subtitle shown below the label; may be {@code null} - * @param checked whether the checkbox is pre-checked - * @param hasCheckbox whether the option has a checkbox marker at all + * @param display user-facing label shown in the menu + * @param response string sent back to Claude when this option is selected + * @param description optional subtitle shown below the label; may be {@code null} + * @param checked whether the checkbox is pre-checked + * @param hasCheckbox whether the option has a checkbox marker at all + * @param hasTextInput whether the option expects free-text input (submitted with \r) */ public record Option( @JsonProperty("display") String display, @@ -36,7 +39,9 @@ public record Option( @JsonInclude(JsonInclude.Include.NON_DEFAULT) @JsonProperty("checked") boolean checked, @JsonInclude(JsonInclude.Include.NON_DEFAULT) - @JsonProperty("hasCheckbox") boolean hasCheckbox) { + @JsonProperty("hasCheckbox") boolean hasCheckbox, + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + @JsonProperty("hasTextInput") boolean hasTextInput) { /** Jackson deserialization constructor. */ @JsonCreator @@ -44,28 +49,28 @@ public record Option( } /** - * Convenience constructor without description, checked or hasCheckbox state. + * Convenience constructor without description, checked, hasCheckbox, or hasTextInput. * * @param display user-facing label * @param response string sent back to Claude */ public Option(String display, String response) { - this(display, response, null, false, false); + this(display, response, null, false, false, false); } /** - * Convenience constructor without checked or hasCheckbox state. + * Convenience constructor without checked, hasCheckbox, or hasTextInput. * * @param display user-facing label * @param response string sent back to Claude * @param description optional subtitle; may be {@code null} */ public Option(String display, String response, String description) { - this(display, response, description, false, false); + this(display, response, description, false, false, false); } /** - * Convenience constructor without hasCheckbox state. + * Convenience constructor without hasCheckbox or hasTextInput. * * @param display user-facing label * @param response string sent back to Claude @@ -73,7 +78,21 @@ public Option(String display, String response, String description) { * @param checked whether the checkbox is pre-checked */ public Option(String display, String response, String description, boolean checked) { - this(display, response, description, checked, false); + this(display, response, description, checked, false, false); + } + + /** + * Convenience constructor without hasTextInput. + * + * @param display user-facing label + * @param response string sent back to Claude + * @param description optional subtitle; may be {@code null} + * @param checked whether the checkbox is pre-checked + * @param hasCheckbox whether the option has a checkbox marker + */ + public Option(String display, String response, String description, boolean checked, + boolean hasCheckbox) { + this(display, response, description, checked, hasCheckbox, false); } } diff --git a/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java b/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java index 32f17ba7..4bf4a3a4 100644 --- a/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java +++ b/src/main/java/io/github/nbclaudecodegui/process/ScreenContentDetector.java @@ -175,8 +175,17 @@ public Optional detectChoiceMenu(List screenLines) { String line = raw.trim(); if (line.isBlank()) break; if (OPTION_LINE.matcher(line).matches()) break; - // Skip keyboard-hint lines - if (isHintLine(line)) break; + // Skip keyboard-hint lines; "shift+tab to approve" also marks the option + // as requiring free-text input (hasTextInput=true). + if (isHintLine(line)) { + if (line.toLowerCase().startsWith("shift+tab")) { + ChoiceMenuModel.Option prev = options.get(oi); + options.set(oi, new ChoiceMenuModel.Option( + prev.display(), prev.response(), prev.description(), + prev.checked(), prev.hasCheckbox(), true)); + } + break; + } // Skip separator lines if (isSeparatorLine(line)) break; // Description must be indented (at least 1 leading space) and not too deeply @@ -741,7 +750,8 @@ private static boolean isHintLine(String trimmed) { // or start with typical shortcut keywords. if (trimmed.contains("\u00B7")) return true; // · String lower = trimmed.toLowerCase(); - return lower.startsWith("esc ") || lower.startsWith("enter to") || lower.startsWith("tab to"); + return lower.startsWith("esc ") || lower.startsWith("enter to") || lower.startsWith("tab to") + || lower.startsWith("shift+tab"); } /** diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java b/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java index a782b8ea..8f2cae6d 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ChoiceMenuPanel.java @@ -581,7 +581,7 @@ private void cancel() { * (display text starts with "type", case-insensitive). */ private static boolean isTypeInputOption(ChoiceMenuModel.Option opt) { - return opt.display().trim().toLowerCase().startsWith("type"); + return opt.display().trim().toLowerCase().startsWith("type") || opt.hasTextInput(); } /** Shows {@code hint} as greyed-out placeholder text; clears on focus, restores on blur if empty. */ diff --git a/src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.expectedRes.json b/src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.expectedRes.json new file mode 100644 index 00000000..b13ee368 --- /dev/null +++ b/src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.expectedRes.json @@ -0,0 +1,10 @@ +{ + "text": "Claude has written up a plan and is ready to execute. Would you like to proceed?", + "options": [ + {"display": "Yes, auto-accept edits", "response": "1"}, + {"display": "Yes, manually approve edits", "response": "2"}, + {"display": "No, refine with Ultraplan on Claude Code on the web", "response": "3"}, + {"display": "Tell Claude what to change", "response": "4", "hasTextInput": true} + ], + "defaultOptionIndex": 0 +} diff --git a/src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.src.txt b/src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.src.txt new file mode 100644 index 00000000..9492c2c3 --- /dev/null +++ b/src/test/resources/io/github/nbclaudecodegui/process/screen-content-detector/detect-choice-menu/ultraplan-tell-claude.src.txt @@ -0,0 +1,11 @@ +╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ + + Claude has written up a plan and is ready to execute. Would you like to proceed? + + ❯ 1. Yes, auto-accept edits + 2. Yes, manually approve edits + 3. No, refine with Ultraplan on Claude Code on the web + 4. Tell Claude what to change + shift+tab to approve with this feedback + + ctrl-g to edit in nano · ~/.claude/plans/write-a-hello-world-serialized-kazoo.md