diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62bee033..c457f252 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 7f184932..c9a94ee0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbclaudecodegui
netbeans-claude-code-gui
- 0.21.59-SNAPSHOT
+ 0.21.60-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 e381d929..7c5a308a 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