diff --git a/CHANGELOG.md b/CHANGELOG.md
index 315d0846..df48bbc7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Added hang detection: configurable timeout (default 60 s) kills the process and shows an error if no PTY output is received after launch; set to 0 to disable
- Fixed authentication for profiles with the "Claude API" connection type: the API key is now correctly recognised by Claude Code on session start
- Added capability of changing the profile storage directory and sharing a single directory with several profiles
- Fixed drag-and-drop into text fields: files are now inserted at the text caret position instead of where the mouse was dropped
diff --git a/pom.xml b/pom.xml
index 8e0ab695..41ca9862 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.github.nbclaudecodegui
netbeans-claude-code-gui
- 0.21.27-SNAPSHOT
+ 0.21.28-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/controller/ClaudeSessionController.java b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java
index 43df3aba..4c4eb232 100644
--- a/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java
+++ b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java
@@ -87,6 +87,18 @@ public final class ClaudeSessionController {
new javax.swing.Timer(400, e -> flushPendingPrompt());
{ promptFlushTimer.setRepeats(false); }
+ /** Timestamp (ms) when the PTY process was launched; used for hang detection. */
+ private volatile long processStartedAt = 0;
+
+ /** {@code true} once the first byte/line from the PTY has been received. */
+ private volatile boolean firstOutputReceived = false;
+
+ /**
+ * Called when a hang is detected: receives (command, errorMessage).
+ * Invoked on the EDT after the process is stopped.
+ */
+ private java.util.function.BiConsumer hangCallback;
+
/** {@code true} while a shift-tab thread is actively switching the CC edit mode. */
private volatile boolean modeSwitchInProgress = false;
@@ -116,6 +128,12 @@ public final class ClaudeSessionController {
private final java.util.Deque recentPtyLines = new java.util.ArrayDeque<>();
private static final int PTY_LINE_BUFFER_SIZE = 40;
+ /** Package-private for tests: sets hang-detection state as if the process just started. */
+ void simulateProcessStart(long startedAt, boolean outputReceived) {
+ processStartedAt = startedAt;
+ firstOutputReceived = outputReceived;
+ }
+
/** Package-private for tests: injects a stripped PTY line into the rolling buffer. */
void simulatePtyLine(String line) {
synchronized (recentPtyLines) {
@@ -190,11 +208,15 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr
mode != null ? mode : SessionMode.NEW,
resumeSessionId);
+ processStartedAt = System.currentTimeMillis();
+ firstOutputReceived = false;
+
connector = new PtyTtyConnector(process);
connector.setSessionTag(tag);
screenContentDetector.setSessionTag(tag);
connector.setLineListener(line -> {
+ firstOutputReceived = true;
LOG.fine(tag + "[PTY line] " + line);
synchronized (recentPtyLines) {
recentPtyLines.addLast(line);
@@ -271,6 +293,8 @@ public void stopProcess() {
customModelIds = List.of();
standardModelCount = 0;
synchronized (recentPtyLines) { recentPtyLines.clear(); }
+ processStartedAt = 0;
+ firstOutputReceived = false;
model.setModelList(List.of(), -1);
model.clearChoiceMenu();
@@ -908,6 +932,18 @@ void pollScreenState() {
// UNKNOWN → no transition
}
+ // Hang detection: if no PTY output received within the configured timeout, kill the process
+ if (!firstOutputReceived && processStartedAt > 0) {
+ int timeoutSec = ClaudeCodePreferences.getHangTimeoutSeconds();
+ if (timeoutSec > 0) {
+ long elapsed = System.currentTimeMillis() - processStartedAt;
+ if (elapsed > timeoutSec * 1000L) {
+ handleHang(timeoutSec);
+ return;
+ }
+ }
+ }
+
// Continuously sync CC screen mode → model (skip during switches and discovery)
if (modelComboPopulated && !modeSwitchInProgress && !modelDiscoveryInProgress) {
Optional detected = screenContentDetector.detectEditMode(lines);
@@ -926,6 +962,34 @@ void pollScreenState() {
}
}
+ /**
+ * Registers a callback invoked on the EDT when a hang is detected and the
+ * process has been stopped.
+ *
+ * @param callback receives (command, errorMessage); may be {@code null} to clear
+ */
+ public void setHangCallback(java.util.function.BiConsumer callback) {
+ this.hangCallback = callback;
+ }
+
+ /**
+ * Called from {@link #pollScreenState()} when the hang watchdog fires.
+ * Stops the process and notifies the registered callback on the EDT.
+ *
+ * @param timeoutSec the configured timeout that elapsed (for the error message)
+ */
+ private void handleHang(int timeoutSec) {
+ LOG.warning("Hang detected: no PTY output received within " + timeoutSec + " s — killing process");
+ String command = claudeProcess != null ? claudeProcess.getLastCommand() : "";
+ stopProcess();
+ java.util.function.BiConsumer cb = hangCallback;
+ if (cb != null) {
+ String msg = "No output received within " + timeoutSec
+ + " seconds. Process was killed.";
+ cb.accept(command, msg);
+ }
+ }
+
private void detectAndApplyInitialEditMode(List lines) {
screenContentDetector.detectEditMode(lines).ifPresent(m -> model.setEditMode(m));
}
diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java
index b8a68852..f9ed6149 100644
--- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java
+++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java
@@ -68,6 +68,8 @@ public final class ClaudeCodeOptionsPanel extends JPanel {
private javax.swing.JCheckBox startNewSessionCheck;
/** Spinner for the maximum number of sessions shown in the session list. */
private JSpinner sessionListLimitSpinner;
+ /** Spinner for the hang-detection timeout in seconds (0 = disabled). */
+ private JSpinner hangTimeoutSpinner;
/** send-key radio buttons: value → button */
private final Map sendRadios = new LinkedHashMap<>();
@@ -227,6 +229,14 @@ private JPanel buildGeneralPanel() {
form.add(sessionListLimitSpinner, gbc(1, row, false));
row++;
+ // --- hang timeout ---
+ form.add(new JLabel("Hang timeout (seconds, 0=disabled):"), gbc(0, row, false));
+ hangTimeoutSpinner = new JSpinner(new SpinnerNumberModel(
+ ClaudeCodePreferences.DEFAULT_HANG_TIMEOUT_SECONDS, 0, 3600, 5));
+ hangTimeoutSpinner.setToolTipText("Kill the process if no PTY output is received within this many seconds after launch (0 = disabled)");
+ form.add(hangTimeoutSpinner, gbc(1, row, false));
+ row++;
+
// spacer
GridBagConstraints spacer = new GridBagConstraints();
spacer.gridx = 0; spacer.gridy = row;
@@ -304,6 +314,7 @@ void load() {
ClaudeCodePreferences.getContextMenuSessionMode()
== io.github.nbclaudecodegui.model.SessionMode.NEW);
sessionListLimitSpinner.setValue(ClaudeCodePreferences.getSessionListLimit());
+ hangTimeoutSpinner.setValue(ClaudeCodePreferences.getHangTimeoutSeconds());
String sendVal = ClaudeCodePreferences.getSendKey();
String newlineVal = ClaudeCodePreferences.getNewlineKey();
@@ -336,6 +347,7 @@ void store() {
? io.github.nbclaudecodegui.model.SessionMode.NEW
: io.github.nbclaudecodegui.model.SessionMode.CONTINUE_LAST);
ClaudeCodePreferences.setSessionListLimit((Integer) sessionListLimitSpinner.getValue());
+ ClaudeCodePreferences.setHangTimeoutSeconds((Integer) hangTimeoutSpinner.getValue());
ClaudeCodePreferences.setSendKey(selectedValue(sendRadios));
ClaudeCodePreferences.setNewlineKey(selectedValue(newlineRadios));
diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java
index 5eb07180..01d9b9d0 100644
--- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java
+++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java
@@ -496,6 +496,35 @@ public static void setSessionListLimit(int v) {
.putInt(KEY_SESSION_LIST_LIMIT, Math.max(1, Math.min(500, v)));
}
+ // -------------------------------------------------------------------------
+ // hangTimeoutSeconds
+ // -------------------------------------------------------------------------
+
+ /** Preference key: seconds of PTY silence before a hang is declared (0 = disabled). */
+ public static final String KEY_HANG_TIMEOUT_SECONDS = "hangTimeoutSeconds";
+ /** Default: 60 seconds. */
+ public static final int DEFAULT_HANG_TIMEOUT_SECONDS = 60;
+
+ /**
+ * Returns the configured hang-detection timeout in seconds.
+ *
+ * @return timeout in seconds, or {@code 0} to disable hang detection
+ */
+ public static int getHangTimeoutSeconds() {
+ return NbPreferences.forModule(ClaudeCodePreferences.class)
+ .getInt(KEY_HANG_TIMEOUT_SECONDS, DEFAULT_HANG_TIMEOUT_SECONDS);
+ }
+
+ /**
+ * Persists the hang-detection timeout.
+ *
+ * @param v timeout in seconds; clamped to [0, 3600]; 0 disables detection
+ */
+ public static void setHangTimeoutSeconds(int v) {
+ NbPreferences.forModule(ClaudeCodePreferences.class)
+ .putInt(KEY_HANG_TIMEOUT_SECONDS, Math.max(0, Math.min(3600, v)));
+ }
+
private static String validated(String value, String fallback) {
return ENTER.equals(value) || SHIFT_ENTER.equals(value)
|| CTRL_ENTER.equals(value) || ALT_ENTER.equals(value)
diff --git a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
index dfb8a688..85164ad8 100644
--- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
+++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
@@ -280,6 +280,7 @@ public ClaudeSessionTab(File dir) {
setIcon(ImageUtilities.loadImage(ICON, true));
updateDisplayName(dir);
model.addListener(this);
+ controller.setHangCallback(this::showStartError);
}
// -------------------------------------------------------------------------
diff --git a/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java b/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java
index ec1dcca4..00f65dc9 100644
--- a/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java
+++ b/src/test/java/io/github/nbclaudecodegui/controller/ClaudeSessionControllerTest.java
@@ -552,6 +552,71 @@ void detectEditMode_emptyResultMeansDefaultModeOnIdleScreen() {
+ "sendShiftTabsUntilMode uses empty+target=default as success condition");
}
+ // -------------------------------------------------------------------------
+ // Hang detection
+ // -------------------------------------------------------------------------
+
+ @Test
+ void hangDetectedWhenNoOutputReceivedWithinTimeout() {
+ // Simulate process started 70 s ago with no output; timeout = 60 s
+ controller.simulateProcessStart(System.currentTimeMillis() - 70_000L, false);
+
+ AtomicReference callbackCommand = new AtomicReference<>();
+ AtomicReference callbackError = new AtomicReference<>();
+ controller.setHangCallback((cmd, err) -> {
+ callbackCommand.set(cmd);
+ callbackError.set(err);
+ });
+
+ // pollScreenState() is called on the EDT via Timer — invoke directly in test
+ controller.pollScreenState();
+
+ assertNotNull(callbackCommand.get(), "Hang callback should have been invoked");
+ assertTrue(callbackError.get().contains("60"), "Error message should include the timeout seconds");
+ }
+
+ @Test
+ void hangNotDetectedWhenOutputAlreadyReceived() {
+ // Simulate process started 70 s ago but output was received
+ controller.simulateProcessStart(System.currentTimeMillis() - 70_000L, true);
+
+ AtomicReference callbackCommand = new AtomicReference<>();
+ controller.setHangCallback((cmd, err) -> callbackCommand.set(cmd));
+
+ controller.pollScreenState();
+
+ assertNull(callbackCommand.get(), "Hang callback should NOT fire when output was received");
+ }
+
+ @Test
+ void hangNotDetectedWhenTimeoutIsZero() {
+ // Simulate process started 70 s ago with no output; timeout = 0 (disabled)
+ // We need to temporarily override the preference — but preferences require NB platform.
+ // Instead, verify via processStartedAt=0 (stopProcess sets it to 0, meaning no active process).
+ controller.simulateProcessStart(0L, false);
+
+ AtomicReference callbackCommand = new AtomicReference<>();
+ controller.setHangCallback((cmd, err) -> callbackCommand.set(cmd));
+
+ controller.pollScreenState();
+
+ assertNull(callbackCommand.get(), "Hang callback should NOT fire when processStartedAt=0");
+ }
+
+ @Test
+ void hangNotDetectedWhenWithinTimeout() {
+ // Simulate process started 10 s ago with no output; default timeout = 60 s
+ controller.simulateProcessStart(System.currentTimeMillis() - 10_000L, false);
+
+ AtomicReference callbackCommand = new AtomicReference<>();
+ controller.setHangCallback((cmd, err) -> callbackCommand.set(cmd));
+
+ // Since NbPreferences returns default (60 s) in tests, 10 s < 60 s → no hang
+ controller.pollScreenState();
+
+ assertNull(callbackCommand.get(), "Hang callback should NOT fire when elapsed < timeout");
+ }
+
// -------------------------------------------------------------------------
// No-op listener base class
// -------------------------------------------------------------------------