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