diff --git a/CHANGELOG.md b/CHANGELOG.md
index 394b0266..bf931ce1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/pom.xml b/pom.xml
index 8cde9216..6e23a2bc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbplugins
netbeans-plugin-claude-code-gui
- 1.2.21-SNAPSHOT
+ 1.2.23-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 2744fc05..89a7e00a 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionController.java
@@ -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);
}
diff --git a/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java b/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java
index 8c408c9c..65188632 100644
--- a/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java
+++ b/src/main/java/io/github/nbplugins/claudecodegui/process/ScreenContentDetector.java
@@ -560,13 +560,14 @@ public DetectedSessionState detectSessionState(List 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;
}
@@ -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();
+ }
}
diff --git a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java
index 6960b7e3..78dbcb21 100644
--- a/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java
+++ b/src/test/java/io/github/nbplugins/claudecodegui/controller/ClaudeSessionControllerTest.java
@@ -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 workingScreen = Arrays.asList(
+ "Some previous Claude output here",
+ "────────────────",
+ "❯",
+ "────────────────",
+ " esc to interrupt"
+ );
+ AtomicReference 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 workingScreen = Arrays.asList(
+ "Some previous Claude output here",
+ "────────────────",
+ "❯",
+ "────────────────",
+ " esc to interrupt"
+ );
+ List readyScreen = Arrays.asList(
+ "Some previous Claude output here",
+ "────────────────",
+ "❯",
+ "────────────────",
+ " ⏸ plan mode on · ? for shortcuts"
+ );
+ AtomicReference> screenRef = new AtomicReference<>(workingScreen);
+ AtomicReference 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
// -------------------------------------------------------------------------
diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/process/screen-content-detector/detect-session-state/ready-named-separator-above-dotted-branch.expectedState.txt b/src/test/resources/io/github/nbplugins/claudecodegui/process/screen-content-detector/detect-session-state/ready-named-separator-above-dotted-branch.expectedState.txt
new file mode 100644
index 00000000..a37f2c28
--- /dev/null
+++ b/src/test/resources/io/github/nbplugins/claudecodegui/process/screen-content-detector/detect-session-state/ready-named-separator-above-dotted-branch.expectedState.txt
@@ -0,0 +1 @@
+READY
\ No newline at end of file
diff --git a/src/test/resources/io/github/nbplugins/claudecodegui/process/screen-content-detector/detect-session-state/ready-named-separator-above-dotted-branch.src.txt b/src/test/resources/io/github/nbplugins/claudecodegui/process/screen-content-detector/detect-session-state/ready-named-separator-above-dotted-branch.src.txt
new file mode 100644
index 00000000..04b2b8d0
--- /dev/null
+++ b/src/test/resources/io/github/nbplugins/claudecodegui/process/screen-content-detector/detect-session-state/ready-named-separator-above-dotted-branch.src.txt
@@ -0,0 +1,6 @@
+✻ Cogitated for 12m 3s
+
+──────────────────────── refactor-menu-reorder-e9.5-e9.8 ──
+❯
+────────────────────────────────────────────────────────────
+ ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents