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
Binary file modified docs/screenshots/save-and-switch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions docs/user-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,10 @@ While a session is running, you can close it and, optionally, open a different o
**Close button (⏻) in the status bar** → opens the **Save & Switch** dialog:

- **Session name field** — pre-filled with the current session name. Giving a session a descriptive name makes it easier to find it later in the session list when using "Resume specific".
- **Mode panel** — choose what happens next: **Close only** (just close, don't open another session) / New session / Continue last / Resume specific. For Resume specific, a session table is shown — see [Session selector panel](#session-selector-panel) for details.
- **Mode panel** — choose what happens next:
- **Close only** — stop the session and close the tab. The conversation is saved and can be resumed later.
- **Restart Advanced...** — stop the session and return to the session selector panel in the same tab, allowing full reconfiguration (directory, profile, mode) before starting a new session.
- **New session** / **Continue last** / **Resume specific** — stop and immediately start another session in the same tab. For Resume specific, a session table is shown — see [Session selector panel](#session-selector-panel) for details.
- Click **OK** to close (and optionally rename) the session and open the next one as chosen. When "Resume specific" is selected, you can also double-click a row in the session table to confirm immediately.

![Save & Switch dialog](screenshots/save-and-switch.png)
Expand All @@ -566,7 +569,8 @@ While a session is running, you can close it and, optionally, open a different o
| Save current session and start a fresh one (can return later) | **Via ⏻:** optionally fill in a session name → New session → OK<br>**Via context menu:** right-click input area → Start New Session (no rename option) |
| Start fresh without being able to return to current context | Type `/clear` in the input area and send it |
| Switch to a specific past session | ⏻ → Resume specific → pick session → OK (or double-click the row)<br>or: right-click input area → Switch to Session… → pick session |
| Close without opening another session | × on the tab<br>or: ⏻ → Close only → OK<br>or: ⏻ → fill in a session name → Close only → OK |
| Close tab, keep session for later | × on the tab<br>or: ⏻ → Close only → OK<br>or: ⏻ → fill in a session name → Close only → OK |
| Stop session, reconfigure everything, then start | ⏻ → Restart Advanced... → OK — returns to the selector panel in the same tab |

### Renaming a session

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.nbclaudecodegui</groupId>
<artifactId>netbeans-claude-code-gui</artifactId>
<version>0.21.18-SNAPSHOT</version>
<version>0.21.22-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans 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 @@ -6,6 +6,9 @@
* <ul>
* <li>{@link #CLOSE_ONLY} — stop the current session without starting a new one
* (only meaningful in the Save &amp; Switch dialog).</li>
* <li>{@link #RESTART_ADVANCED} — stop the current session and return to the
* selector panel in the same tab, allowing full reconfiguration before
* starting a new session (only meaningful in the Save &amp; Switch dialog).</li>
* <li>{@link #NEW} — start a fresh session (no {@code --continue} or
* {@code --resume} flag).</li>
* <li>{@link #CONTINUE_LAST} — resume the most recent session via
Expand All @@ -16,6 +19,7 @@
*/
public enum SessionMode {
CLOSE_ONLY,
RESTART_ADVANCED,
NEW,
CONTINUE_LAST,
RESUME_SPECIFIC
Expand Down
56 changes: 40 additions & 16 deletions src/main/java/io/github/nbclaudecodegui/process/ClaudeProcess.java
Original file line number Diff line number Diff line change
Expand Up @@ -174,22 +174,7 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl
}
}

List<String> extra = parseArgs(extraCliArgs);
if (!extra.isEmpty()) {
cmd.addAll(extra);
LOG.info("Extra CLI args appended: " + extra);
}

// Session mode flags
if (mode == SessionMode.CONTINUE_LAST) {
cmd.add("--continue");
LOG.info("Session mode: --continue");
} else if (mode == SessionMode.RESUME_SPECIFIC
&& resumeSessionId != null && !resumeSessionId.isBlank()) {
cmd.add("--resume");
cmd.add(resumeSessionId);
LOG.info("Session mode: --resume " + resumeSessionId);
}
appendSessionFlags(cmd, workingDir, env, extraCliArgs, mode, resumeSessionId);

lastCommand = String.join(" ", cmd);
LOG.info("Claude command: " + lastCommand);
Expand Down Expand Up @@ -227,6 +212,45 @@ public PtyProcess start(String workingDir, ClaudeProfile profile, String extraCl
/** Returns the last command attempted to start, as a space-joined string. */
public String getLastCommand() { return lastCommand; }

/**
* Appends extra CLI args and session-mode flags to {@code cmd}.
*
* <p>Package-private for unit testing.
*
* @param cmd command list to append to (already contains executable and MCP flags)
* @param workingDir working directory (used to check for existing sessions)
* @param env environment map (used to resolve {@code CLAUDE_CONFIG_DIR})
* @param extraCliArgs extra CLI arguments string (may be blank)
* @param mode session mode
* @param resumeSessionId session ID for RESUME_SPECIFIC mode
*/
void appendSessionFlags(List<String> cmd, String workingDir, Map<String, String> env,
String extraCliArgs, SessionMode mode, String resumeSessionId) {
List<String> extra = parseArgs(extraCliArgs);
if (!extra.isEmpty()) {
cmd.addAll(extra);
LOG.info("Extra CLI args appended: " + extra);
}

if (mode == SessionMode.CONTINUE_LAST) {
String configDirStr = env.get("CLAUDE_CONFIG_DIR");
Path claudeConfigDir = configDirStr != null
? Path.of(configDirStr)
: Path.of(System.getProperty("user.home"), ".claude");
if (ClaudeSessionStore.hasAnySessions(Path.of(workingDir), claudeConfigDir)) {
cmd.add("--continue");
LOG.fine("Session mode: --continue (existing session found)");
} else {
LOG.fine("Session mode: CONTINUE_LAST — no sessions found, starting new");
}
} else if (mode == SessionMode.RESUME_SPECIFIC
&& resumeSessionId != null && !resumeSessionId.isBlank()) {
cmd.add("--resume");
cmd.add(resumeSessionId);
LOG.fine("Session mode: --resume " + resumeSessionId);
}
}

/**
* Stops the current PTY process and cleans up {@code settings.local.json}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ static Path sessionsDir(Path workingDir, Path claudeConfigDir) {
return configDir.resolve("projects").resolve(computeHash(workingDir));
}

// -------------------------------------------------------------------------
// Existence check
// -------------------------------------------------------------------------

/**
* Returns {@code true} if at least one session JSONL file exists for the
* given working directory. Does not parse any file content.
*
* @param workingDir working directory to check
* @param claudeConfigDir Claude config base dir; {@code null} means {@code ~/.claude}
* @return {@code true} if any {@code .jsonl} file is found
*/
public static boolean hasAnySessions(Path workingDir, Path claudeConfigDir) {
Path dir = sessionsDir(workingDir, claudeConfigDir);
if (!Files.isDirectory(dir)) {
return false;
}
try (Stream<Path> files = Files.list(dir)) {
return files.anyMatch(p -> p.getFileName().toString().endsWith(".jsonl"));
} catch (IOException e) {
LOG.fine("Could not check sessions in " + dir + ": " + e.getMessage());
return false;
}
}

// -------------------------------------------------------------------------
// List
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ public ClaudeSessionSelectorPanel(File initialDirectory, OpenListener openListen
if (initialDirectory != null) {
pathCombo.setSelectedItem(initialDirectory.getAbsolutePath());
}
pathCombo.addActionListener(e -> reloadSessionModePanel());

// --- browse button ---
browseButton = new JButton(NbBundle.getMessage(ClaudeSessionSelectorPanel.class, "BTN_Browse"));
Expand Down Expand Up @@ -395,8 +396,7 @@ private void onProjectSelected() {
}

private void reloadSessionModePanel() {
if (sessionModePanel != null
&& sessionModePanel.getSelectedMode() == SessionMode.RESUME_SPECIFIC) {
if (sessionModePanel != null) {
sessionModePanel.reload(
resolveDir() != null ? resolveDir().toPath() : null,
resolveClaudeConfigDir(),
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -938,16 +938,25 @@ public void onSaveAndSwitch(String sessionName, SessionMode mode, String resumeI
cleanupChatLayout();

if (mode == SessionMode.CLOSE_ONLY) {
// Just stop — do nothing more
doCloseTab();
return;
}

if (mode == SessionMode.RESTART_ADVANCED) {
return; // selector already shown by cleanupChatLayout()
}

// Restart with new mode
if (dir != null) {
startSession(dir, profileName, extraCliArgs, mode, resumeId);
}
}

/** Closes this tab. Protected to allow overriding in tests. */
protected void doCloseTab() {
close();
}

private void stopProcess() {
controller.stopProcess();
cleanupChatLayout();
Expand Down
29 changes: 18 additions & 11 deletions src/main/java/io/github/nbclaudecodegui/ui/SessionModePanel.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public final class SessionModePanel extends JPanel {
// -------------------------------------------------------------------------

private final JRadioButton closeOnlyRadio;
private final JRadioButton restartAdvancedRadio;
private final JRadioButton newRadio;
private final JRadioButton continueRadio;
private final JRadioButton resumeRadio;
Expand Down Expand Up @@ -94,12 +95,14 @@ public SessionModePanel(Path workingDir, Path claudeConfigDir,

ButtonGroup group = new ButtonGroup();

closeOnlyRadio = new JRadioButton("Close only");
newRadio = new JRadioButton("New session");
continueRadio = new JRadioButton("Continue last");
resumeRadio = new JRadioButton("Resume specific");
closeOnlyRadio = new JRadioButton("Close only");
restartAdvancedRadio = new JRadioButton("Restart Advanced...");
newRadio = new JRadioButton("New session");
continueRadio = new JRadioButton("Continue last");
resumeRadio = new JRadioButton("Resume specific");

group.add(closeOnlyRadio);
group.add(restartAdvancedRadio);
group.add(newRadio);
group.add(continueRadio);
group.add(resumeRadio);
Expand All @@ -110,6 +113,7 @@ public SessionModePanel(Path workingDir, Path claudeConfigDir,

if (showCloseOnly) {
radioPanel.add(row(closeOnlyRadio));
radioPanel.add(row(restartAdvancedRadio));
}
radioPanel.add(row(newRadio));
radioPanel.add(row(continueRadio));
Expand Down Expand Up @@ -169,6 +173,7 @@ public void componentResized(java.awt.event.ComponentEvent e) {

// Radio listeners
closeOnlyRadio.addActionListener(e -> onRadioChanged());
restartAdvancedRadio.addActionListener(e -> onRadioChanged());
newRadio.addActionListener(e -> onRadioChanged());
continueRadio.addActionListener(e -> onRadioChanged());
resumeRadio.addActionListener(e -> onRadioChanged());
Expand Down Expand Up @@ -206,9 +211,10 @@ public void mouseClicked(MouseEvent e) {
* @return selected {@link SessionMode}
*/
public SessionMode getSelectedMode() {
if (closeOnlyRadio.isSelected()) return SessionMode.CLOSE_ONLY;
if (newRadio.isSelected()) return SessionMode.NEW;
if (continueRadio.isSelected()) return SessionMode.CONTINUE_LAST;
if (closeOnlyRadio.isSelected()) return SessionMode.CLOSE_ONLY;
if (restartAdvancedRadio.isSelected()) return SessionMode.RESTART_ADVANCED;
if (newRadio.isSelected()) return SessionMode.NEW;
if (continueRadio.isSelected()) return SessionMode.CONTINUE_LAST;
return SessionMode.RESUME_SPECIFIC;
}

Expand All @@ -233,10 +239,11 @@ public String getSelectedSessionId() {
*/
public void setMode(SessionMode mode) {
switch (mode) {
case CLOSE_ONLY -> closeOnlyRadio.setSelected(true);
case NEW -> newRadio.setSelected(true);
case CONTINUE_LAST -> continueRadio.setSelected(true);
case RESUME_SPECIFIC -> resumeRadio.setSelected(true);
case CLOSE_ONLY -> closeOnlyRadio.setSelected(true);
case RESTART_ADVANCED -> restartAdvancedRadio.setSelected(true);
case NEW -> newRadio.setSelected(true);
case CONTINUE_LAST -> continueRadio.setSelected(true);
case RESUME_SPECIFIC -> resumeRadio.setSelected(true);
}
onRadioChanged();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,66 @@ private static void resetPrefs(File script) {
if (script != null) script.delete();
}

// -------------------------------------------------------------------------
// appendSessionFlags — --continue only when sessions exist
// -------------------------------------------------------------------------

@Test
void appendSessionFlags_continueLast_noSessionsDir_noFlag(@TempDir Path tmpDir) {
ClaudeProcess cp = new ClaudeProcess();
java.util.List<String> cmd = new java.util.ArrayList<>(java.util.List.of("claude"));
// tmpDir has no sessions subdirectory → no --continue
cp.appendSessionFlags(cmd, tmpDir.toString(), java.util.Map.of(),
"", io.github.nbclaudecodegui.model.SessionMode.CONTINUE_LAST, null);
assertFalse(cmd.contains("--continue"),
"should NOT add --continue when no sessions exist");
}

@Test
void appendSessionFlags_continueLast_sessionsExist_addsFlag(@TempDir Path tmpDir) throws Exception {
// Create a fake sessions dir with one .jsonl file
String hash = tmpDir.toString().replace('/', '-');
Path sessionsDir = tmpDir.resolve(".claude").resolve("projects").resolve(hash);
Files.createDirectories(sessionsDir);
Files.writeString(sessionsDir.resolve("fake-session.jsonl"), "{}\n");

ClaudeProcess cp = new ClaudeProcess();
java.util.List<String> cmd = new java.util.ArrayList<>(java.util.List.of("claude"));
cp.appendSessionFlags(cmd, tmpDir.toString(),
java.util.Map.of("CLAUDE_CONFIG_DIR", tmpDir.resolve(".claude").toString()),
"", io.github.nbclaudecodegui.model.SessionMode.CONTINUE_LAST, null);
assertTrue(cmd.contains("--continue"),
"should add --continue when sessions exist");
}

@Test
void appendSessionFlags_newMode_noFlag(@TempDir Path tmpDir) throws Exception {
// Even if sessions exist, NEW mode should not add any session flag
String hash = tmpDir.toString().replace('/', '-');
Path sessionsDir = tmpDir.resolve(".claude").resolve("projects").resolve(hash);
Files.createDirectories(sessionsDir);
Files.writeString(sessionsDir.resolve("fake-session.jsonl"), "{}\n");

ClaudeProcess cp = new ClaudeProcess();
java.util.List<String> cmd = new java.util.ArrayList<>(java.util.List.of("claude"));
cp.appendSessionFlags(cmd, tmpDir.toString(),
java.util.Map.of("CLAUDE_CONFIG_DIR", tmpDir.resolve(".claude").toString()),
"", io.github.nbclaudecodegui.model.SessionMode.NEW, null);
assertFalse(cmd.contains("--continue"), "NEW mode should not add --continue");
assertFalse(cmd.contains("--resume"), "NEW mode should not add --resume");
}

@Test
void appendSessionFlags_resumeSpecific_addsResumeFlag(@TempDir Path tmpDir) {
ClaudeProcess cp = new ClaudeProcess();
java.util.List<String> cmd = new java.util.ArrayList<>(java.util.List.of("claude"));
cp.appendSessionFlags(cmd, tmpDir.toString(), java.util.Map.of(),
"", io.github.nbclaudecodegui.model.SessionMode.RESUME_SPECIFIC, "abc-123");
assertTrue(cmd.contains("--resume"), "should add --resume");
assertTrue(cmd.contains("abc-123"), "should add the session id");
assertFalse(cmd.contains("--continue"), "should not add --continue");
}

// -------------------------------------------------------------------------
// parseArgs
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.github.nbclaudecodegui.ui;

import io.github.nbclaudecodegui.model.SessionMode;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

/**
* Verifies that {@link ClaudeSessionTab#onSaveAndSwitch} closes the tab when
* {@link SessionMode#CLOSE_ONLY} is selected.
*/
class ClaudeSessionTabCloseOnlyTest {

@Test
void onSaveAndSwitch_closeOnly_closesTab() throws Exception {
AtomicBoolean closed = new AtomicBoolean(false);

ClaudeSessionTab tab = new ClaudeSessionTab() {
@Override
protected void doCloseTab() {
closed.set(true);
}
};

tab.onSaveAndSwitch("", SessionMode.CLOSE_ONLY, null);

assertTrue(closed.get(), "Tab must be closed when CLOSE_ONLY mode is selected");
}

@Test
void onSaveAndSwitch_newMode_doesNotCloseTab() throws Exception {
AtomicBoolean closed = new AtomicBoolean(false);

ClaudeSessionTab tab = new ClaudeSessionTab() {
@Override
protected void doCloseTab() {
closed.set(true);
}
};

tab.onSaveAndSwitch("", SessionMode.NEW, null);

assertFalse(closed.get(), "Tab must not be closed when NEW mode is selected");
}

@Test
void onSaveAndSwitch_restartAdvanced_doesNotCloseTab() throws Exception {
AtomicBoolean closed = new AtomicBoolean(false);

ClaudeSessionTab tab = new ClaudeSessionTab() {
@Override
protected void doCloseTab() {
closed.set(true);
}
};

tab.onSaveAndSwitch("", SessionMode.RESTART_ADVANCED, null);

assertFalse(closed.get(), "Tab must not be closed when RESTART_ADVANCED mode is selected");
}
}
Loading
Loading