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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- Fixed session becoming permanently unresponsive when the working directory is on a git branch whose name contains dots (e.g. `feature/e9.5-refactor`)

# 1.2.20 (2026-06-22)

- Fixed documentation artifact name in GitHub Releases to match the plugin artifact name
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.2.21-SNAPSHOT</version>
<version>1.2.23-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 @@ -1083,10 +1083,15 @@ void pollScreenState() {
// Sending /model while Claude is blocked by such a dialog causes the
// model menu to appear only after the dialog is dismissed, by which
// time discovery has already timed out and given up.
// Also defer when the screen shows "esc to interrupt": detectInputPromptReady
// returns true even while Claude is WORKING (the ❯ area is always visible),
// so without this guard, discoverModels() would fire immediately, send /model
// to a busy Claude, time out, and leave the session permanently stuck in WORKING.
if (model.getLifecycle() == SessionLifecycle.READY
&& !modelComboPopulated && !modelDiscoveryInProgress
&& model.getActiveChoiceMenu() == null
&& screenContentDetector.detectChoiceMenu(lines).isEmpty()) {
&& screenContentDetector.detectChoiceMenu(lines).isEmpty()
&& screenContentDetector.detectSessionState(lines) != ScreenContentDetector.DetectedSessionState.WORKING) {
discoverModels();
detectAndApplyInitialEditMode(lines);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,13 +560,14 @@ public DetectedSessionState detectSessionState(List<String> lines) {
if (INPUT_PROMPT.matcher(line.trim()).find()) {
boolean separatorAbove = i > 0 && (
isSeparatorLine(lines.get(i - 1).trim()) ||
PLAN_NAME_DASHES_PATTERN.matcher(lines.get(i - 1).trim()).find()
PLAN_NAME_DASHES_PATTERN.matcher(lines.get(i - 1).trim()).find() ||
isSeparatorLineWithLabel(lines.get(i - 1).trim())
);
// Autocomplete popup lines may appear between ❯ and the separator below —
// scan up to 5 lines down to find it.
boolean separatorBelow = false;
for (int j = i + 1; j < lines.size() && j <= i + 5; j++) {
if (isSeparatorLine(lines.get(j).trim())) {
if (isSeparatorLine(lines.get(j).trim()) || isSeparatorLineWithLabel(lines.get(j).trim())) {
separatorBelow = true;
break;
}
Expand Down Expand Up @@ -782,4 +783,25 @@ private static boolean isSeparatorLine(String trimmed) {
if (trimmed.length() < 4) return false;
return trimmed.chars().allMatch(c -> c == '\u2500' || c == '\u2501' || c == '\u2014' || c == '-' || c == '=');
}

private static boolean isBoxDash(int c) {
return c == '\u2500' || c == '\u2501' || c == '\u2014' || c == '-' || c == '=';
}

/**
* Returns true for the input-box top border that embeds a label (e.g. git branch name):
* "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 refactor-menu-reorder-e9.5-e9.8 \u2500\u2500".
* {@link #isSeparatorLine} rejects it (embedded text) and
* {@link #PLAN_NAME_DASHES_PATTERN} rejects labels with dots or slashes, so without this
* helper {@code detectSessionState} would mis-classify the idle screen as WORKING.
* A line qualifies when it starts and ends with box-drawing dashes and those dashes
* make up at least half of its characters.
*/
private static boolean isSeparatorLineWithLabel(String trimmed) {
if (trimmed.length() < 4) return false;
if (!isBoxDash(trimmed.charAt(0)) || !isBoxDash(trimmed.charAt(1))) return false;
if (!isBoxDash(trimmed.charAt(trimmed.length() - 1))) return false;
long dashes = trimmed.chars().filter(ScreenContentDetector::isBoxDash).count();
return dashes * 2 >= trimmed.length();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,92 @@ void pollScreenStateSetsNewMenuAfterStaleMenuCleared() throws Exception {
assertNotNull(m.getActiveChoiceMenu(), "new menu must be detected after stale cleared");
}

// -------------------------------------------------------------------------
// pollScreenState — model discovery deferred while screen shows WORKING
// -------------------------------------------------------------------------

/**
* Regression: when --continue resumes a session where Claude is still actively
* working, detectInputPromptReady() returns true (❯ is always visible), causing
* STARTING → READY, and then discoverModels() fires immediately. discoverModels()
* sets lifecycle back to WORKING and sends /model to a busy Claude, which times
* out and leaves the session stuck permanently in WORKING.
*
* Fix: model discovery must be deferred when detectSessionState returns WORKING
* (i.e. "esc to interrupt" is visible in the footer).
*/
@Test
void pollScreenState_doesNotTriggerModelDiscovery_whileScreenShowsWorking() throws Exception {
List<String> workingScreen = Arrays.asList(
"Some previous Claude output here",
"────────────────",
"❯",
"────────────────",
" esc to interrupt"
);
AtomicReference<Integer> discoveryCalls = new AtomicReference<>(0);
ClaudeSessionModel m = new ClaudeSessionModel();
ClaudeSessionController c = new ClaudeSessionController(m, () -> workingScreen) {
@Override void discoverModels() { discoveryCalls.set(discoveryCalls.get() + 1); }
};

// Session starts in STARTING; first poll: STARTING→READY (❯ visible),
// then READY→WORKING (esc to interrupt); discovery must be deferred
c.pollScreenState();

assertEquals(SessionLifecycle.WORKING, m.getLifecycle(),
"lifecycle must be WORKING: STARTING→READY then READY→WORKING (esc to interrupt)");
assertEquals(0, (int) discoveryCalls.get(),
"discoverModels must NOT fire while screen shows 'esc to interrupt'");
}

/**
* Verifies that model discovery IS triggered once the screen transitions to
* truly idle state (❯ visible, no "esc to interrupt").
*/
@Test
void pollScreenState_triggersModelDiscovery_whenScreenBecomesReady() throws Exception {
List<String> workingScreen = Arrays.asList(
"Some previous Claude output here",
"────────────────",
"❯",
"────────────────",
" esc to interrupt"
);
List<String> readyScreen = Arrays.asList(
"Some previous Claude output here",
"────────────────",
"❯",
"────────────────",
" ⏸ plan mode on · ? for shortcuts"
);
AtomicReference<List<String>> screenRef = new AtomicReference<>(workingScreen);
AtomicReference<Integer> discoveryCalls = new AtomicReference<>(0);
ClaudeSessionModel m = new ClaudeSessionModel();
ClaudeSessionController c = new ClaudeSessionController(m, screenRef::get) {
@Override void discoverModels() { discoveryCalls.set(discoveryCalls.get() + 1); }
};

// First poll: STARTING → READY → WORKING; discovery deferred
c.pollScreenState();
assertEquals(SessionLifecycle.WORKING, m.getLifecycle());
assertEquals(0, (int) discoveryCalls.get(), "no discovery while screen shows WORKING");

// Screen transitions to idle
screenRef.set(readyScreen);

// Second poll: WORKING → READY (screen sync fires at end of poll);
// model discovery guard runs before the sync, so discovery not yet triggered
c.pollScreenState();
assertEquals(SessionLifecycle.READY, m.getLifecycle(),
"lifecycle must be READY once screen shows idle state");

// Third poll: lifecycle is READY, screen is READY → discovery fires
c.pollScreenState();
assertEquals(1, (int) discoveryCalls.get(),
"discoverModels must fire once the screen becomes truly idle");
}

// -------------------------------------------------------------------------
// custom model list — appended after standard models in discoverModels
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
READY
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
✻ Cogitated for 12m 3s

──────────────────────── refactor-menu-reorder-e9.5-e9.8 ──
❯
────────────────────────────────────────────────────────────
⏵⏵ auto mode on (shift+tab to cycle) · ← for agents
Loading