diff --git a/CHANGELOG.md b/CHANGELOG.md index 53289336..41b66da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +- Added Advanced tab in Tools → Options → Claude Code with Debug mode, Hang timeout, and a new "Enable MCP integration" toggle (disabling it skips the --mcp-config flag while hooks remain active) +- Fixed process start errors and hang timeouts now logged to the IDE log file at SEVERE level +- 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 MCP config not working on Windows due to quote stripping in CreateProcess (https://github.com/nbclaudecodegui/NetbeansClaudeCodeGui/issues/13) - Fixed authentication for profiles with the "Claude API" connection type: the API key is now correctly recognised by Claude Code on session start # 0.20.4 (2026-04-09) diff --git a/docs/screenshots/settings-advanced.png b/docs/screenshots/settings-advanced.png new file mode 100644 index 00000000..59885b08 Binary files /dev/null and b/docs/screenshots/settings-advanced.png differ diff --git a/docs/screenshots/settings-favorites.png b/docs/screenshots/settings-favorites.png new file mode 100644 index 00000000..3395b3ef Binary files /dev/null and b/docs/screenshots/settings-favorites.png differ diff --git a/docs/screenshots/settings-general.png b/docs/screenshots/settings-general.png new file mode 100644 index 00000000..d786910d Binary files /dev/null and b/docs/screenshots/settings-general.png differ diff --git a/docs/user-manual.md b/docs/user-manual.md index 661bee27..9763f6c7 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -330,6 +330,8 @@ Open **Tools → Options → Claude Code** in NetBeans. ### General tab +![Settings — General tab](screenshots/settings-general.png) + | Setting | Default | Description | |---------|---------|-------------| | Claude CLI path | (empty) | Absolute path to the `claude` executable. Leave empty to use the system `PATH`. | @@ -338,12 +340,23 @@ Open **Tools → Options → Claude Code** in NetBeans. | History TTL (days) | 0 | Number of days after which history entries expire. 0 = keep forever. | | Send prompt key | Ctrl+Enter | Key combination that sends the prompt from the input area. | | Insert newline key | Enter | Key combination that inserts a newline in the input area. The send and newline keys are configured independently but cannot be set to the same value. | -| Debug mode | Off | Enables verbose logging of all Claude I/O to the NetBeans log file and the Output window. | | Open diff in a separate tab | Off | Opens the diff panel in a new IDE tab instead of embedding it in the session tab. | | Show markdown preview for .md files in diff | On | Shows a rendered markdown preview alongside the raw diff for `.md` files. | +### Advanced tab + +![Settings — Advanced tab](screenshots/settings-advanced.png) + +| Setting | Default | Description | +|---------|---------|-------------| +| Debug mode | Off | Enables verbose logging of all Claude I/O to the NetBeans log file and the Output window. | +| Hang timeout (seconds) | 60 | Kill the process if no PTY output is received within this many seconds after launch. Set to 0 to disable. | +| Enable MCP integration | On | When unchecked, the `--mcp-config` flag is not passed to `claude`. Hooks (PreToolUse) are always configured regardless of this setting. Disable only if MCP causes issues. | + ### Favorites tab +![Settings — Favorites tab](screenshots/settings-favorites.png) + Manages global favorites — prompts available in every project. The table shows all global favorites with columns: ☐ (checkbox for multi-select), **Text**, **Shortcut**. @@ -360,6 +373,10 @@ The table shows all global favorites with columns: ☐ (checkbox for multi-selec The search field filters entries by text. +### Profiles tab + +See [Section 12 — Profiles](#12-profiles) for full details on creating and managing profiles. + --- ## 12. Profiles @@ -544,14 +561,14 @@ As you move the cursor or change the selection in any open editor, the plugin au If Claude says a tool is unavailable (e.g. "I don't have access to `getWorkspaceFolders`"): 1. Check that no other application is using the MCP server port (**Tools → Options → Claude Code → General → MCP server port**, default: 28991). Change the port if needed and restart the IDE. -2. Enable **Debug mode** and check the log file (`~/.netbeans//var/log/messages.log`) for `MCP SSE server started on port`. +2. Enable **Debug mode** (**Tools → Options → Claude Code → Advanced**) and check the log file (`~/.netbeans//var/log/messages.log`) for `MCP SSE server started on port`. --- ## 14. Troubleshooting ### Enable debug mode -Go to **Tools → Options → Claude Code → General** and check **Debug mode**. This writes detailed logs of all Claude I/O (PTY bytes, MCP messages, hook calls) to the NetBeans log file. +Go to **Tools → Options → Claude Code → Advanced** and check **Debug mode**. This writes detailed logs of all Claude I/O (PTY bytes, MCP messages, hook calls) to the NetBeans log file. ### Log file location ``` diff --git a/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java index e6a05e28..5d5243e8 100644 --- a/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java +++ b/src/main/java/io/github/nbclaudecodegui/controller/ClaudeSessionController.java @@ -84,6 +84,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; @@ -113,6 +125,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) { @@ -167,11 +185,15 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr customModelIds = profile != null ? new ArrayList<>(profile.getCustomModels()) : List.of(); PtyProcess process = claudeProcess.start(dir.getAbsolutePath(), profile, extraCliArgs != null ? extraCliArgs : ""); + 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); @@ -248,6 +270,8 @@ public void stopProcess() { customModelIds = List.of(); standardModelCount = 0; synchronized (recentPtyLines) { recentPtyLines.clear(); } + processStartedAt = 0; + firstOutputReceived = false; model.setModelList(List.of(), -1); model.clearChoiceMenu(); @@ -861,6 +885,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); @@ -879,6 +915,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/process/ClaudeProcess.java b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java index f3748a11..1977f2cc 100644 --- a/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java +++ b/src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java @@ -68,6 +68,9 @@ public ClaudeProcess() {} /** Working directory of the current session; {@code null} when stopped. */ private volatile String workingDir; + /** Temp file holding the --mcp-config JSON; {@code null} when not in use. */ + private volatile Path mcpConfigTempFile; + /** * Starts a Claude CLI PTY process in the given working directory using * the Default profile (no extra env vars injected). @@ -143,11 +146,25 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl + ", port=" + (mcp != null ? mcp.getServerPort() : -1)); if (mcp != null && mcp.isServerRunning()) { int port = mcp.getServerPort(); - // Pass MCP server config via --mcp-config (Claude 2.x no longer reads - // mcpServers from settings.local.json; --mcp-config works in TUI mode). - cmd.add("--mcp-config"); - cmd.add(buildMcpConfigJson(port)); - LOG.info("Passing --mcp-config with netbeans SSE server on port " + port); + if (io.github.nbclaudecodegui.settings.ClaudeCodePreferences.isMcpEnabled()) { + // Pass MCP server config via --mcp-config (Claude 2.x no longer reads + // mcpServers from settings.local.json; --mcp-config works in TUI mode). + // On Windows, inline JSON gets quote-stripped by CreateProcess, so we + // write to a temp file and pass the file path instead. + cmd.add("--mcp-config"); + try { + Path tmpCfg = writeMcpConfigTempFile(port); + mcpConfigTempFile = tmpCfg; + cmd.add(tmpCfg.toAbsolutePath().toString()); + LOG.info("Passing --mcp-config via temp file: " + tmpCfg); + } catch (IOException e) { + LOG.warning("Could not write --mcp-config temp file, falling back to inline JSON: " + e.getMessage()); + cmd.add(buildMcpConfigJson(port)); + LOG.info("Passing --mcp-config with netbeans SSE server on port " + port); + } + } else { + LOG.info("MCP integration disabled by user preference; skipping --mcp-config"); + } try { writeSettingsLocalJson(workingDir, port, profile); } catch (IOException e) { @@ -205,7 +222,14 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl * {@value #OUR_HOOK_MATCHER}). User-provided keys are left untouched. * If the file becomes empty after cleanup it is deleted. */ - public void stop() { + /** + * Kills the PTY process only — does NOT delete settings.local.json or the + * temp MCP config file. File cleanup is deferred to {@link #stop()}. + * + *

Use this when the error panel is about to be shown and files must stay + * alive until the user dismisses the panel. + */ + public void killOnly() { PtyProcess p = ptyProcess; if (p != null && p.isAlive()) { p.destroy(); @@ -219,6 +243,20 @@ public void stop() { } } ptyProcess = null; + // workingDir and mcpConfigTempFile intentionally NOT cleared — deferred to stop() + } + + public void stop() { + killOnly(); + + Path tmp = mcpConfigTempFile; + mcpConfigTempFile = null; + if (tmp != null) { + try { + Files.deleteIfExists(tmp); + LOG.fine("Deleted --mcp-config temp file: " + tmp); + } catch (IOException e) { /* ignore */ } + } String dir = workingDir; workingDir = null; @@ -556,6 +594,48 @@ static Map buildEnv(ClaudeProfile profile, java.nio.file.Path pr * Builds a minimal {@code settings.local.json} from scratch for the given port. * Used as a fallback when the existing file cannot be parsed. */ + /** + * Converts a command list to a shell-pasteable string. + * Arguments that contain spaces, quotes, or other shell-special characters + * are quoted so the result can be pasted directly into a terminal. + * On Windows, double-quotes inside an argument are doubled (""). + * On other platforms, they are backslash-escaped (\"). + */ + static String toShellCommand(List cmd) { + boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win"); + return toShellCommand(cmd, windows); + } + + static String toShellCommand(List cmd, boolean windows) { + StringBuilder sb = new StringBuilder(); + for (String arg : cmd) { + if (sb.length() > 0) sb.append(' '); + boolean needsQuoting = arg.isEmpty() || arg.chars().anyMatch(c -> + c == ' ' || c == '\t' || c == '"' || c == '\'' || c == '\\' || + c == '{' || c == '}' || c == '(' || c == ')' || + c == '&' || c == '|' || c == '<' || c == '>'); + if (!needsQuoting) { + sb.append(arg); + } else if (windows) { + sb.append('"').append(arg.replace("\"", "\"\"")).append('"'); + } else { + sb.append('"').append(arg.replace("\"", "\\\"")).append('"'); + } + } + return sb.toString(); + } + + /** + * Writes the MCP config JSON to a temp file and returns its path. + * The file is registered for deletion on JVM exit as a safety net. + */ + static Path writeMcpConfigTempFile(int port) throws IOException { + Path tmp = Files.createTempFile("claude-mcp-config-", ".json"); + tmp.toFile().deleteOnExit(); + Files.writeString(tmp, buildMcpConfigJson(port), StandardCharsets.UTF_8); + return tmp; + } + /** * Builds the JSON string to pass as {@code --mcp-config} to the Claude CLI. */ diff --git a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java index 610d6e22..e89acbc3 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodeOptionsPanel.java @@ -64,6 +64,10 @@ public final class ClaudeCodeOptionsPanel extends JPanel { private javax.swing.JCheckBox diffInSessionCheck; /** Checkbox to show markdown preview for .md files in diff. */ private javax.swing.JCheckBox mdPreviewInDiffCheck; + /** Spinner for the hang-detection timeout in seconds (0 = disabled). */ + private JSpinner hangTimeoutSpinner; + /** Checkbox to enable MCP integration (pass --mcp-config flag). */ + private javax.swing.JCheckBox mcpEnabledCheckBox; /** send-key radio buttons: value → button */ private final Map sendRadios = new LinkedHashMap<>(); @@ -93,6 +97,7 @@ private void initComponents() { JTabbedPane tabs = new JTabbedPane(); tabs.addTab("General", buildGeneralPanel()); + tabs.addTab("Advanced", buildAdvancedPanel()); tabs.addTab("Favorites", favoritesPanel); tabs.addTab("Profiles", profilesPanel); add(tabs, BorderLayout.CENTER); @@ -175,16 +180,6 @@ private JPanel buildGeneralPanel() { newlineRadios.get(val).addActionListener(e -> syncExclusion(val, false)); } - // --- debug mode --- - debugCheckBox = new javax.swing.JCheckBox("Debug mode (log all claude I/O to NetBeans log and output area)"); - GridBagConstraints dbgGbc = new GridBagConstraints(); - dbgGbc.gridx = 0; dbgGbc.gridy = row; - dbgGbc.gridwidth = 3; - dbgGbc.anchor = GridBagConstraints.WEST; - dbgGbc.insets = new Insets(4, 8, 4, 8); - form.add(debugCheckBox, dbgGbc); - row++; - // --- diff in session --- diffInSessionCheck = new javax.swing.JCheckBox("Open diff in a separate tab"); GridBagConstraints diffGbc = new GridBagConstraints(); @@ -215,6 +210,64 @@ private JPanel buildGeneralPanel() { return form; } + /** + * Builds the Advanced tab containing debug and hang-timeout settings. + * + * @return the Advanced tab panel + */ + private JPanel buildAdvancedPanel() { + JPanel form = new JPanel(new GridBagLayout()); + int row = 0; + + // --- debug mode --- + debugCheckBox = new javax.swing.JCheckBox("Debug mode (log all claude I/O to NetBeans log and output area)"); + GridBagConstraints dbgGbc = new GridBagConstraints(); + dbgGbc.gridx = 0; dbgGbc.gridy = row; + dbgGbc.gridwidth = 2; + dbgGbc.anchor = GridBagConstraints.WEST; + dbgGbc.insets = new Insets(8, 8, 4, 8); + form.add(debugCheckBox, dbgGbc); + 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)"); + GridBagConstraints hangSpinnerGbc = gbc(1, row, false); + hangSpinnerGbc.weightx = 1.0; + form.add(hangTimeoutSpinner, hangSpinnerGbc); + row++; + + // --- MCP enabled --- + mcpEnabledCheckBox = new javax.swing.JCheckBox("Enable MCP integration"); + GridBagConstraints mcpGbc = new GridBagConstraints(); + mcpGbc.gridx = 0; mcpGbc.gridy = row; + mcpGbc.gridwidth = 2; + mcpGbc.anchor = GridBagConstraints.WEST; + mcpGbc.insets = new Insets(12, 8, 0, 8); + form.add(mcpEnabledCheckBox, mcpGbc); + row++; + + JLabel mcpDesc = new JLabel("When disabled, the --mcp-config flag is not passed to claude. Hooks are always configured."); + GridBagConstraints mcpDescGbc = new GridBagConstraints(); + mcpDescGbc.gridx = 0; mcpDescGbc.gridy = row; + mcpDescGbc.gridwidth = 2; + mcpDescGbc.anchor = GridBagConstraints.WEST; + mcpDescGbc.insets = new Insets(0, 28, 4, 8); + form.add(mcpDesc, mcpDescGbc); + row++; + + // spacer + GridBagConstraints spacer = new GridBagConstraints(); + spacer.gridx = 0; spacer.gridy = row; + spacer.gridwidth = 2; spacer.weighty = 1.0; + spacer.fill = GridBagConstraints.VERTICAL; + form.add(new JPanel(), spacer); + + return form; + } + private JPanel buildKeyPanel(String title, Map radios, ButtonGroup group) { JPanel panel = new JPanel(new java.awt.FlowLayout( @@ -278,6 +331,8 @@ void load() { diffInSessionCheck.setSelected( ClaudeCodePreferences.isOpenDiffInSeparateTab()); mdPreviewInDiffCheck.setSelected(ClaudeCodePreferences.isMdPreviewInDiff()); + hangTimeoutSpinner.setValue(ClaudeCodePreferences.getHangTimeoutSeconds()); + mcpEnabledCheckBox.setSelected(ClaudeCodePreferences.isMcpEnabled()); String sendVal = ClaudeCodePreferences.getSendKey(); String newlineVal = ClaudeCodePreferences.getNewlineKey(); @@ -305,6 +360,8 @@ void store() { ClaudeCodePreferences.setDebugMode(debugCheckBox.isSelected()); ClaudeCodePreferences.setOpenDiffInSeparateTab(diffInSessionCheck.isSelected()); ClaudeCodePreferences.setMdPreviewInDiff(mdPreviewInDiffCheck.isSelected()); + ClaudeCodePreferences.setHangTimeoutSeconds((Integer) hangTimeoutSpinner.getValue()); + ClaudeCodePreferences.setMcpEnabled(mcpEnabledCheckBox.isSelected()); 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 208ce8f9..97432735 100644 --- a/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java +++ b/src/main/java/io/github/nbclaudecodegui/settings/ClaudeCodePreferences.java @@ -422,6 +422,61 @@ public static void setDefaultExtraCliArgs(String v) { .put(KEY_DEFAULT_EXTRA_CLI_ARGS, v != null ? 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))); + } + + // ------------------------------------------------------------------------- + // mcpEnabled + // ------------------------------------------------------------------------- + + /** Preference key: pass {@code --mcp-config} flag to claude CLI. */ + private static final String MCP_ENABLED = "mcpEnabled"; + + /** + * Returns whether MCP integration is enabled (default {@code true}). + * + * @return {@code true} to pass {@code --mcp-config}; {@code false} to skip it + */ + public static boolean isMcpEnabled() { + return NbPreferences.forModule(ClaudeCodePreferences.class) + .getBoolean(MCP_ENABLED, true); + } + + /** + * Persists the MCP-enabled flag. + * + * @param v {@code true} to enable MCP integration + */ + public static void setMcpEnabled(boolean v) { + NbPreferences.forModule(ClaudeCodePreferences.class).putBoolean(MCP_ENABLED, 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 f26367d0..c9d42935 100644 --- a/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java +++ b/src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java @@ -263,6 +263,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 // ------------------------------------------------------------------------- diff --git a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java index 96cc147f..f70c09b3 100644 --- a/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java +++ b/src/test/java/io/github/nbclaudecodegui/process/ClaudeProcessTest.java @@ -3,6 +3,7 @@ import com.pty4j.PtyProcess; import io.github.nbclaudecodegui.settings.ClaudeProfile; import java.io.File; +import java.util.List; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -170,6 +171,73 @@ void testCleanupRemovesApiKeyHelperButKeepsUserContent() { assertTrue(cleaned.contains("myCustomKey"), "cleaned should preserve user content"); } + // ------------------------------------------------------------------------- + // toShellCommand + // ------------------------------------------------------------------------- + + @Test + void testToShellCommandNoQuoting() { + List cmd = List.of("claude", "--continue"); + // plain args need no quoting on either platform + assertEquals("claude --continue", ClaudeProcess.toShellCommand(cmd, false)); + assertEquals("claude --continue", ClaudeProcess.toShellCommand(cmd, true)); + } + + @Test + void testToShellCommandQuotesJsonArgUnix() { + String json = ClaudeProcess.buildMcpConfigJson(28991); + List cmd = List.of("claude", "--mcp-config", json, "--continue"); + String result = ClaudeProcess.toShellCommand(cmd, false); + // Expected: claude --mcp-config "{\"mcpServers\":...}" --continue + assertTrue(result.startsWith("claude --mcp-config \""), "should quote JSON arg"); + assertTrue(result.endsWith("\" --continue"), "should close quote before next arg"); + String inner = result.substring("claude --mcp-config \"".length(), + result.lastIndexOf("\" --continue")); + // On Unix all " inside must be escaped as \" + for (int i = 0; i < inner.length(); i++) { + if (inner.charAt(i) == '"') { + assertTrue(i > 0 && inner.charAt(i - 1) == '\\', + "unescaped double-quote at position " + i + " in: " + inner); + } + } + } + + @Test + void testToShellCommandQuotesJsonArgWindows() { + String json = ClaudeProcess.buildMcpConfigJson(28991); + List cmd = List.of("claude", "--mcp-config", json, "--continue"); + String result = ClaudeProcess.toShellCommand(cmd, true); + // Expected: claude --mcp-config "{""mcpServers"":...}" --continue + assertTrue(result.startsWith("claude --mcp-config \""), "should quote JSON arg"); + assertTrue(result.endsWith("\" --continue"), "should close quote before next arg"); + String inner = result.substring("claude --mcp-config \"".length(), + result.lastIndexOf("\" --continue")); + // On Windows all " inside must be doubled as "" + assertFalse(inner.contains("\\\""), "Windows quoting must not use backslash-escape"); + assertTrue(inner.contains("\"\""), "Windows quoting must double double-quotes"); + } + + // ------------------------------------------------------------------------- + // writeMcpConfigTempFile + // ------------------------------------------------------------------------- + + @Test + void testWriteMcpConfigTempFile() throws Exception { + Path tmp = ClaudeProcess.writeMcpConfigTempFile(28991); + try { + assertTrue(Files.exists(tmp), "temp file should be created"); + String content = Files.readString(tmp, StandardCharsets.UTF_8); + assertTrue(content.contains("netbeans"), "temp file should contain netbeans key"); + assertTrue(content.contains("28991"), "temp file should contain port"); + assertTrue(content.startsWith("{"), "temp file should be valid JSON object"); + // File path should contain no double-quotes (safe to pass as process arg) + assertFalse(tmp.toAbsolutePath().toString().contains("\""), + "temp file path should contain no double-quotes"); + } finally { + Files.deleteIfExists(tmp); + } + } + // ------------------------------------------------------------------------- // buildMcpConfigJson // -------------------------------------------------------------------------