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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
- Added session management when opening a session from the toolbar and when closing a tab via the Close button
- Added configurable session mode: choose to resume the last session or start a new one

# 0.20 (2026-04-09)

- Added Maven Central publication support: `release` Maven profile with GPG signing and Sonatype Central Portal deployment, plus CI `deploy` job that publishes artifacts on release builds
Expand Down
66 changes: 57 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal sessio

### Process startup

`ClaudeProcess.start(workingDir)`:
`ClaudeProcess.start(workingDir, profile, extraCliArgs, mode, resumeSessionId)`:
1. Looks up `ClaudeCodeStatusService` → gets the MCP SSE server port (default **28991**, configurable in Tools → Options).
2. **Merges** (not overwrites) `{workingDir}/.claude/settings.local.json` — only the plugin's own keys are added/updated; all other keys are left untouched:
```json
Expand All @@ -75,8 +75,9 @@ A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal sessio
}
```
On session stop, the plugin removes its own keys; if the file becomes empty it is deleted.
3. Launches `claude` (no `--print`, `TERM=xterm-256color`) via `pty4j` `PtyProcessBuilder`.
4. `PtyTtyConnector` bridges the PTY master to JediTerm's `TtyConnector`. The full Claude TUI renders natively.
3. Appends session flags based on `SessionMode`: `--continue` for CONTINUE_LAST, `--resume <id>` for RESUME_SPECIFIC, nothing for NEW.
4. Launches `claude` (no `--print`, `TERM=xterm-256color`) via `pty4j` `PtyProcessBuilder`.
5. `PtyTtyConnector` bridges the PTY master to JediTerm's `TtyConnector`. The full Claude TUI renders natively.

`MCPSseServer` (Jetty) starts at IDE startup (`ClaudeCodeInstaller`) and is shared across all sessions.

Expand Down Expand Up @@ -107,18 +108,18 @@ The plugin follows an MVC structure for session management:

| Package | Responsibility |
|---------|---------------|
| `model/` | `ClaudeSessionModel` — session state container, `SessionLifecycle` enum, `EDIT_MODE_REGISTRY` (cross-thread), listener dispatch on EDT; `ChoiceMenuModel` — interactive-prompt data; `PromptHistoryStore`, `PromptFavoritesStore`, `HistoryEntry`, `FavoriteEntry` — persistent history/favorites |
| `controller/` | `ClaudeSessionController` — PTY process lifecycle, connector wiring, screen polling, model/edit-mode switching, `parseModelDiscovery` |
| `process/` | `ClaudeProcess` — PTY process start/stop/version + `settings.local.json` merge/cleanup; `PtyTtyConnector` — PTY↔JediTerm bridge; `ScreenContentDetector` — screen-poll analysis; `StreamJsonParser` — lightweight NDJSON parser |
| `ui/` | `ClaudeSessionTab` — one TC per session; `ClaudePromptPanel` — passive View, implements `ClaudeSessionModelListener`; `ChoiceMenuPanel` — interactive choice UI; `FileDiffTab` + `FileDiffPermissionPanel` — diff viewer with Accept/Reject/Cancel; `MarkdownRenderer` — markdown→HTML; `HistoryDialog`, `FavoritesDialog`, `FavoritesPanel`, `AssignShortcutDialog` — history/favorites UI |
| `model/` | `ClaudeSessionModel` — session state container, `SessionLifecycle` enum, `EDIT_MODE_REGISTRY` (cross-thread), listener dispatch on EDT; `ChoiceMenuModel` — interactive-prompt data; `PromptHistoryStore`, `PromptFavoritesStore`, `HistoryEntry`, `FavoriteEntry` — persistent history/favorites; `SessionMode` — NEW / CONTINUE_LAST / RESUME_SPECIFIC / CLOSE_ONLY; `SavedSession` — record (sessionId, createdAt, lastAt, slug, customTitle, firstPrompt) |
| `controller/` | `ClaudeSessionController` — PTY process lifecycle, connector wiring, screen polling, model/edit-mode switching, `parseModelDiscovery`; `stopAndRename(name)` — stops PTY then writes custom-title entry to JSONL |
| `process/` | `ClaudeProcess` — PTY process start/stop/version + `settings.local.json` merge/cleanup; `PtyTtyConnector` — PTY↔JediTerm bridge; `ScreenContentDetector` — screen-poll analysis; `StreamJsonParser` — lightweight NDJSON parser; `ClaudeSessionStore` — reads/writes Claude session JSONL files (`~/.claude/projects/<hash>/`): list, rename, delete |
| `ui/` | `ClaudeSessionTab` — one TC per session; `ClaudePromptPanel` — passive View, implements `ClaudeSessionModelListener`; `ChoiceMenuPanel` — interactive choice UI; `FileDiffTab` + `FileDiffPermissionPanel` — diff viewer with Accept/Reject/Cancel; `MarkdownRenderer` — markdown→HTML; `HistoryDialog`, `FavoritesDialog`, `FavoritesPanel`, `AssignShortcutDialog` — history/favorites UI; `SaveAndSwitchDialog` — modal for stop/rename/switch session; `SessionModePanel` — reusable radio-button panel for session mode + session table; `ClaudeSessionSelectorPanel` — directory/profile selector with integrated `SessionModePanel` |
| `ui/common/` | Shared input components: `AtCompletionPopup` — @-triggered path popup; `AtPathHighlighter` — blue token highlight; `FileDropHandler` — DnD + Ctrl+V → @path insertion; `ShortcutMatcher` — key→shortcut match with KEY_TYPED suppression; `TextComponentDecorator` — wires all the above; `DecoratedTextArea/TextField`, `TextContextMenu`, `RangeHighlightable` |
| `settings/` | `ClaudeCodePreferences` — NbPreferences wrapper; `ClaudeCodeOptionsPanelController` / `ClaudeCodeOptionsPanel` — Tools→Options (General + Profiles tabs); `ClaudeProfile`, `ClaudeProfileStore` — named profiles with isolated `CLAUDE_CONFIG_DIR`, auth, proxy, extra env vars; `ClaudeProjectProperties` — per-project profile assignment |
| `settings/` | `ClaudeCodePreferences` — NbPreferences wrapper (keys: `contextMenuSessionMode` default CONTINUE_LAST, `sessionListLimit` default 30); `ClaudeCodeOptionsPanelController` / `ClaudeCodeOptionsPanel` — Tools→Options (General + Profiles tabs); `ClaudeProfile`, `ClaudeProfileStore` — named profiles with isolated `CLAUDE_CONFIG_DIR`, auth, proxy, extra env vars; `ClaudeProjectProperties` — per-project profile assignment |
| `actions/` | `ClaudeCodeAction` — toolbar button; `OpenWithClaudeAction` — project node context menu; `PromptFavoriteAction` — dynamically registered action per favorite for Keymap API |
| `io.github.nbclaudecodegui.mcp` | `MCPSseServer` — Jetty `/sse` `/messages` `/hook`; `NetBeansMCPHandler` — MCP dispatcher + PreToolUse hook; `tools/` — `OpenDiff`, `PermissionPromptTool`, `DiffTabTracker`, `GetDiagnostics`, `GetOpenEditors`, `GetCurrentSelection`, `OpenFile` |
| `org.openbeans.claude.netbeans` | Legacy classes: `ClaudeCodeStatusService`, `ClaudeCodeStatusLineElement`, `EditorUtils`, `NbUtils`; `tools/` — `AsyncHandler`, `AsyncResponse`, `GetWorkspaceFolders`, `CheckDocumentDirty`, `SaveDocument`, `CloseTab`, `CloseAllDiffTabs` |

### Session lifecycle
1. User clicks toolbar → `ClaudeSessionTab` opens; user picks a directory → `ClaudePromptPanel.startProcess()` creates a `JediTermWidget`, then `ClaudeSessionController.startProcess()` merges `settings.local.json` and launches the PTY.
1. User clicks toolbar → `ClaudeSessionTab` opens; user picks a directory and session mode in `ClaudeSessionSelectorPanel` → `ClaudeSessionController.startProcess()` merges `settings.local.json` and launches the PTY.
2. PTY output renders in JediTerm; `ScreenContentDetector` (screen-poll timer) detects interactive choice menus → `ClaudeSessionModel.setActiveChoiceMenu()` → `ClaudePromptPanel.onChoiceMenuChanged()` shows `ChoiceMenuPanel`.
3. Claude connects to `GET /sse`; subsequent tool calls arrive as `POST /messages`; file edits trigger `POST /hook`.
4. Each session = isolated PTY process; closing window confirms if the process is running.
Expand All @@ -132,6 +133,46 @@ sendPrompt() / discoverModels() → WORKING
onClaudeIdle() → READY
```

### Session modes

`SessionMode` controls which CLI flags are appended when launching `claude`:

| Mode | CLI flag | When used |
|------|----------|-----------|
| `NEW` | _(none)_ | Start fresh conversation |
| `CONTINUE_LAST` | `--continue` | Resume most recent session |
| `RESUME_SPECIFIC` | `--resume <sessionId>` | Resume a specific saved session |
| `CLOSE_ONLY` | _(stop, no restart)_ | `SaveAndSwitchDialog` stop-only option |

### Tab open/close behavior

**Opening a session:**
- **Toolbar button** (`ClaudeCodeAction`) → finds an idle `ClaudeSessionTab` and focuses it, or creates a new empty tab.
- **Project context menu** (`OpenWithClaudeAction`) → finds an existing session tab for that directory (focuses it), or creates a new tab and calls `autoStart(dir, profileName)` with the mode from `contextMenuSessionMode` preference.
- **IDE restart** → `readExternal` restores path / profile / extraCliArgs / sessionMode / resumeSessionId → `componentOpened` calls `autoStart` with those values.
- **User confirms selector panel** → `ClaudeSessionSelectorPanel` fires `OpenListener.onOpen(dir, profileName, extraCliArgs, mode, resumeId)` → `ClaudeSessionTab.startSession()`.

**Stopping / switching a session:**
- **Stop button (⏻)** in status bar → `openSwitchDialog(SessionMode.CLOSE_ONLY)` — opens `SaveAndSwitchDialog` pre-selecting CLOSE_ONLY; user optionally renames session, then either stops or switches to a new mode.
- **"New session" button** in prompt panel → `onSaveAndSwitch("", SessionMode.NEW, null)` directly.
- **"Resume session" button** in prompt panel → `openSwitchDialog(SessionMode.RESUME_SPECIFIC)`.
- **Tab × close** → `componentClosed()` saves `pathToRestore` and calls `stopProcess()`.
- **`onSaveAndSwitch(name, mode, resumeId)`**: calls `controller.stopAndRename(name)` (stops PTY, appends custom-title to JSONL if name non-empty), then starts a new session with the chosen mode.

### Session persistence (JSONL)

Claude Code stores sessions as JSONL files:
```
~/.claude/projects/<hash>/<session-id>.jsonl
```
Hash = absolute working-dir path with every `/` replaced by `-`.

`ClaudeSessionStore` operations:
- **`listSessions(workingDir, claudeConfigDir, limit)`** — two-phase read for performance: sort all JSONL files by `mtime` descending, take top candidates, parse only those. Results sorted by `lastAt` descending. Sessions with no timestamps excluded.
- **`renameSession()`** — appends a `{"type":"custom-title","customTitle":"...","sessionId":"...","timestamp":"..."}` entry to the JSONL file.
- **`deleteSession()`** — removes the JSONL file.
- **`SavedSession.displayName()`** — returns `customTitle` if set, otherwise `slug`, otherwise `sessionId`.

### File-change permission flow
Claude Code can ask permission before editing files via two paths, both using `FileDiffTab` + `PermissionPanel`:

Expand All @@ -146,6 +187,13 @@ Both paths show the same UI: `[✓ Accept] [✗ Reject] [Reject reason (Optional

After any action the originating `ClaudeSessionTab` is re-activated automatically.

### Settings (Tools → Options → Claude Code → General)

| Setting | Preference key | Default | Description |
|---------|---------------|---------|-------------|
| "Start new session when opening with Claude" checkbox | `contextMenuSessionMode` | `CONTINUE_LAST` | Checked → `SessionMode.NEW`; unchecked → `SessionMode.CONTINUE_LAST`. Controls the mode used by the toolbar button and project context menu. |
| "Session list limit" spinner (1–500) | `sessionListLimit` | 30 | Max number of past sessions shown in the resume list. |

### Registration
`layer.xml` registers the Options category (position 1500) and toolbar action. Icon PNGs are generated at build time from `cc-gui-icon.svg` via the Groovy script in `pom.xml` using Apache Batik.

Expand Down
102 changes: 102 additions & 0 deletions claude-launch-tests/test_session_rename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Verify that writing a customTitle entry after session close is recognised by Claude Code.

Test scenario:
1. Run `claude --print "say hello"` to create a session JSONL file.
2. Append a custom-title entry to that JSONL file.
3. Run `claude --resume <id> --print "what is your session id"` and verify
the session is loaded (i.e., --resume does not error).
4. Also verify via `ClaudeSessionStore` logic: parse the JSONL and confirm
customTitle is returned as displayName.

Findings:
- Claude Code stores sessions under ~/.claude/projects/<hash>/<session-id>.jsonl
- The hash replaces every '/' in the working dir path with '-'
- customTitle is stored as {"type":"custom-title","customTitle":"...","sessionId":"...","timestamp":"..."}
- Claude Code respects the customTitle when listing sessions
"""
import json, subprocess, pathlib, time, os, re

WORK = pathlib.Path('/home/oleg/my-projects/NetbeansClaudeCodePlugin')
CLAUDE_CONFIG = pathlib.Path.home() / '.claude'
PROJECTS_DIR = CLAUDE_CONFIG / 'projects'
HASH = str(WORK.resolve()).replace('/', '-')
SESSION_DIR = PROJECTS_DIR / HASH

CUSTOM_TITLE = 'test-rename-stage21'


def get_most_recent_session():
"""Return (session_id, jsonl_path) of the most recently modified JSONL in SESSION_DIR."""
if not SESSION_DIR.exists():
return None, None
files = sorted(SESSION_DIR.glob('*.jsonl'), key=lambda p: p.stat().st_mtime, reverse=True)
if not files:
return None, None
path = files[0]
return path.stem, path


def run_claude_print(prompt, resume_id=None):
"""Run claude --print and return (stdout, returncode)."""
cmd = ['claude', '--print', prompt]
if resume_id:
cmd += ['--resume', resume_id]
result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(WORK), timeout=60)
return result.stdout, result.returncode


# ---- Step 1: Create a session ----
print('[1] Running: claude --print "say hello"')
stdout, rc = run_claude_print('say hello')
print(f' returncode={rc}, output (first 100 chars): {stdout[:100]!r}')
assert rc == 0, f'claude --print failed with rc={rc}'

time.sleep(1) # wait for JSONL to be flushed

session_id, session_file = get_most_recent_session()
assert session_id is not None, 'No session JSONL found in ' + str(SESSION_DIR)
print(f'[2] Most recent session: {session_id}')
print(f' File: {session_file}')

# ---- Step 2: Append custom-title entry ----
print(f'[3] Appending customTitle="{CUSTOM_TITLE}" to JSONL')
content = session_file.read_text(encoding='utf-8')
entry = json.dumps({
'type': 'custom-title',
'customTitle': CUSTOM_TITLE,
'sessionId': session_id,
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S.000Z', time.gmtime()),
})
if not content.endswith('\n'):
content += '\n'
content += entry + '\n'
session_file.write_text(content, encoding='utf-8')
print(' Done.')

# ---- Step 3: Parse the JSONL to verify customTitle ----
print('[4] Verifying customTitle in parsed JSONL...')
custom_title_found = None
with open(session_file, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if obj.get('type') == 'custom-title':
custom_title_found = obj.get('customTitle')
except json.JSONDecodeError:
pass

assert custom_title_found == CUSTOM_TITLE, \
f'Expected customTitle="{CUSTOM_TITLE}", got "{custom_title_found}"'
print(f' customTitle="{custom_title_found}" — OK')

# ---- Step 4: Verify --resume accepts the session ID ----
print(f'[5] Running: claude --resume {session_id} --print "say hi"')
stdout2, rc2 = run_claude_print('say hi', resume_id=session_id)
print(f' returncode={rc2}, output (first 100 chars): {stdout2[:100]!r}')
assert rc2 == 0, f'claude --resume failed with rc={rc2}, stderr might have error'

print('\n✓ All assertions passed. Session rename test succeeded.')
Loading
Loading