jupyterlab_claude_code_extension 1.1.23 → 1.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/icons.d.ts +2 -0
- package/lib/icons.js +22 -0
- package/lib/index.js +9 -3
- package/lib/types.d.ts +2 -1
- package/lib/widget.d.ts +12 -1
- package/lib/widget.js +99 -3
- package/package.json +2 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +38 -0
- package/src/icons.ts +26 -0
- package/src/index.ts +11 -3
- package/src/types.ts +2 -1
- package/src/widget.ts +120 -2
- package/style/base.css +18 -0
package/lib/icons.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export declare const starFilledIcon: LabIcon;
|
|
|
4
4
|
export declare const refreshIcon: LabIcon;
|
|
5
5
|
export declare const removeIcon: LabIcon;
|
|
6
6
|
export declare const shieldIcon: LabIcon;
|
|
7
|
+
export declare const addIcon: LabIcon;
|
|
8
|
+
export declare const addShieldIcon: LabIcon;
|
|
7
9
|
export declare const filterIcon: LabIcon;
|
package/lib/icons.js
CHANGED
|
@@ -49,6 +49,28 @@ export const shieldIcon = new LabIcon({
|
|
|
49
49
|
name: 'jupyterlab_claude_code_extension:shield',
|
|
50
50
|
svgstr: shieldSvgStr
|
|
51
51
|
});
|
|
52
|
+
// Material "add" plus, same 16px/jp-icon3 treatment as the other header
|
|
53
|
+
// icons.
|
|
54
|
+
const addSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
|
|
55
|
+
<path class="jp-icon3" fill="#616161" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
|
56
|
+
</svg>`;
|
|
57
|
+
export const addIcon = new LabIcon({
|
|
58
|
+
name: 'jupyterlab_claude_code_extension:add',
|
|
59
|
+
svgstr: addSvgStr
|
|
60
|
+
});
|
|
61
|
+
// Plus with a small solid shield in the lower-right corner - the
|
|
62
|
+
// skip-permissions variant of the "new session" button, echoing the
|
|
63
|
+
// shield used on the context menu's "Resume (Skip Permissions)".
|
|
64
|
+
const addShieldSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
|
|
65
|
+
<g class="jp-icon3" fill="#616161">
|
|
66
|
+
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" transform="translate(-2,-2) scale(0.83)"/>
|
|
67
|
+
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z" transform="translate(13,12) scale(0.46)"/>
|
|
68
|
+
</g>
|
|
69
|
+
</svg>`;
|
|
70
|
+
export const addShieldIcon = new LabIcon({
|
|
71
|
+
name: 'jupyterlab_claude_code_extension:add-shield',
|
|
72
|
+
svgstr: addShieldSvgStr
|
|
73
|
+
});
|
|
52
74
|
// Funnel copied verbatim from @jupyterlab/ui-components'
|
|
53
75
|
// `search/filter.svg` - the same image the file browser's filter
|
|
54
76
|
// toggle uses. The `class="jp-icon3"` lets JupyterLab's theme drive
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
|
|
2
|
+
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
2
3
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
3
4
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
4
5
|
import { requestAPI } from './request';
|
|
@@ -10,8 +11,13 @@ const plugin = {
|
|
|
10
11
|
description: 'Side panel listing Claude Code sessions per project folder, with remote-control indicator, favorites, and one-click resume in a terminal.',
|
|
11
12
|
autoStart: true,
|
|
12
13
|
requires: [ILabShell],
|
|
13
|
-
optional: [
|
|
14
|
-
|
|
14
|
+
optional: [
|
|
15
|
+
ILayoutRestorer,
|
|
16
|
+
ISettingRegistry,
|
|
17
|
+
ITerminalTracker,
|
|
18
|
+
IDefaultFileBrowser
|
|
19
|
+
],
|
|
20
|
+
activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser) => {
|
|
15
21
|
const settings = app.serviceManager.serverSettings;
|
|
16
22
|
let status;
|
|
17
23
|
try {
|
|
@@ -25,7 +31,7 @@ const plugin = {
|
|
|
25
31
|
console.info('[jupyterlab_claude_code_extension] `claude` binary not found on PATH; panel disabled.');
|
|
26
32
|
return;
|
|
27
33
|
}
|
|
28
|
-
const widget = new ClaudeCodeSessionsWidget(app, status.root_dir || '', terminalTracker);
|
|
34
|
+
const widget = new ClaudeCodeSessionsWidget(app, status.root_dir || '', terminalTracker, fileBrowser);
|
|
29
35
|
// Read the sidebar setting before docking so we add the widget to the
|
|
30
36
|
// user's preferred side on first paint. Default to left.
|
|
31
37
|
let currentSidebar = 'left';
|
package/lib/types.d.ts
CHANGED
|
@@ -44,7 +44,8 @@ export interface ICleanupResponse {
|
|
|
44
44
|
}
|
|
45
45
|
export interface ILaunchTerminalRequest {
|
|
46
46
|
project_path: string;
|
|
47
|
-
|
|
47
|
+
/** Omit to start a brand-new claude session instead of resuming one. */
|
|
48
|
+
session_id?: string;
|
|
48
49
|
dangerously_skip_permissions?: boolean;
|
|
49
50
|
}
|
|
50
51
|
export interface ILaunchTerminalResponse {
|
package/lib/widget.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
2
3
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
3
4
|
import { Widget } from '@lumino/widgets';
|
|
4
5
|
import { Message } from '@lumino/messaging';
|
|
5
6
|
export type PresentationMode = 'folder' | 'path';
|
|
6
7
|
export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
7
|
-
constructor(app: JupyterFrontEnd, rootDir: string, terminalTracker?: ITerminalTracker | null);
|
|
8
|
+
constructor(app: JupyterFrontEnd, rootDir: string, terminalTracker?: ITerminalTracker | null, fileBrowser?: IDefaultFileBrowser | null);
|
|
8
9
|
refresh(): void;
|
|
9
10
|
/** Choose how rows are labelled: by session name, folder name, or path. */
|
|
10
11
|
setPresentationMode(mode: PresentationMode): void;
|
|
@@ -44,6 +45,15 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
44
45
|
private _cleanupParallel;
|
|
45
46
|
private _resumeInTerminal;
|
|
46
47
|
private _doResumeInTerminal;
|
|
48
|
+
/** Absolute path of the file browser's current folder; falls back to the
|
|
49
|
+
* server root when no file browser is available. */
|
|
50
|
+
private _currentFolder;
|
|
51
|
+
/** Start a brand-new claude session in the file browser's current folder.
|
|
52
|
+
* Same launch path as resuming (claude is the pty's only process via the
|
|
53
|
+
* launch-terminal endpoint) - just without --resume, and always a fresh
|
|
54
|
+
* terminal since there is no existing session to reuse.
|
|
55
|
+
*/
|
|
56
|
+
private _newSession;
|
|
47
57
|
/**
|
|
48
58
|
* Bring a terminal tab to the front AND hand it keyboard focus, so the
|
|
49
59
|
* user can start typing without an extra click. `activateById` only
|
|
@@ -102,6 +112,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
102
112
|
private _pollHandle;
|
|
103
113
|
private readonly _removingPaths;
|
|
104
114
|
private readonly _terminalTracker;
|
|
115
|
+
private readonly _fileBrowser;
|
|
105
116
|
private readonly _terminalsByPath;
|
|
106
117
|
private readonly _pendingByPath;
|
|
107
118
|
private readonly _rootDir;
|
package/lib/widget.js
CHANGED
|
@@ -3,7 +3,7 @@ import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
|
3
3
|
import { CommandRegistry } from '@lumino/commands';
|
|
4
4
|
import { Menu, Widget } from '@lumino/widgets';
|
|
5
5
|
import { requestAPI } from './request';
|
|
6
|
-
import { claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
|
|
6
|
+
import { addIcon, addShieldIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
|
|
7
7
|
const POLL_INTERVAL_MS = 30000;
|
|
8
8
|
const DEFAULT_RECENT_LIMIT = 10;
|
|
9
9
|
const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
|
|
@@ -56,7 +56,7 @@ catch (_err) {
|
|
|
56
56
|
// ignore
|
|
57
57
|
}
|
|
58
58
|
export class ClaudeCodeSessionsWidget extends Widget {
|
|
59
|
-
constructor(app, rootDir, terminalTracker = null) {
|
|
59
|
+
constructor(app, rootDir, terminalTracker = null, fileBrowser = null) {
|
|
60
60
|
super();
|
|
61
61
|
this._refreshBtn = null;
|
|
62
62
|
this._filterBtn = null;
|
|
@@ -78,6 +78,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
78
78
|
this._serverSettings = app.serviceManager.serverSettings;
|
|
79
79
|
this._rootDir = rootDir.replace(/\/+$/, '');
|
|
80
80
|
this._terminalTracker = terminalTracker;
|
|
81
|
+
this._fileBrowser = fileBrowser;
|
|
81
82
|
this.id = 'jupyterlab-claude-code-extension';
|
|
82
83
|
this.title.icon = claudeIcon;
|
|
83
84
|
this.title.caption = 'Claude Code Sessions';
|
|
@@ -139,6 +140,19 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
139
140
|
title.className = 'jp-ClaudeSessionsPanel-title';
|
|
140
141
|
title.textContent = 'Claude Code Sessions';
|
|
141
142
|
header.appendChild(title);
|
|
143
|
+
const newBtn = document.createElement('button');
|
|
144
|
+
newBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
145
|
+
newBtn.title = 'New Claude session in the current folder';
|
|
146
|
+
addIcon.element({ container: newBtn });
|
|
147
|
+
newBtn.addEventListener('click', () => void this._newSession(false));
|
|
148
|
+
header.appendChild(newBtn);
|
|
149
|
+
const newSkipBtn = document.createElement('button');
|
|
150
|
+
newSkipBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
151
|
+
newSkipBtn.title =
|
|
152
|
+
'New Claude session in the current folder (Skip Permissions)';
|
|
153
|
+
addShieldIcon.element({ container: newSkipBtn });
|
|
154
|
+
newSkipBtn.addEventListener('click', () => void this._newSession(true));
|
|
155
|
+
header.appendChild(newSkipBtn);
|
|
142
156
|
const filterBtn = document.createElement('button');
|
|
143
157
|
filterBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
144
158
|
filterBtn.title = 'Filter sessions';
|
|
@@ -335,17 +349,52 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
335
349
|
}
|
|
336
350
|
}
|
|
337
351
|
async _cleanupParallel(session) {
|
|
352
|
+
const body = new Widget();
|
|
353
|
+
body.node.className = 'jp-ClaudeSessionsPanel-cleanupBody';
|
|
354
|
+
const message = document.createElement('div');
|
|
355
|
+
message.className = 'jp-ClaudeSessionsPanel-cleanupMessage';
|
|
356
|
+
const count = session.extra_sessions;
|
|
357
|
+
message.textContent = `Removing ${count} parallel session${count === 1 ? '' : 's'}...`;
|
|
358
|
+
body.node.appendChild(message);
|
|
359
|
+
// No `value` attribute -> indeterminate (animated) while the request is
|
|
360
|
+
// in flight; set to max on completion so the bar reads as finished.
|
|
361
|
+
const bar = document.createElement('progress');
|
|
362
|
+
bar.className = 'jp-ClaudeSessionsPanel-cleanupProgress';
|
|
363
|
+
bar.max = 1;
|
|
364
|
+
body.node.appendChild(bar);
|
|
365
|
+
const dialog = new Dialog({
|
|
366
|
+
title: 'Clean Up Parallel Sessions',
|
|
367
|
+
body,
|
|
368
|
+
buttons: [Dialog.okButton({ label: 'Close' })]
|
|
369
|
+
});
|
|
370
|
+
// Hide the Close button while work is in progress; restore it once the
|
|
371
|
+
// outcome (success or error) is shown so the user dismisses the popup.
|
|
372
|
+
const footer = dialog.node.querySelector('.jp-Dialog-footer');
|
|
373
|
+
if (footer) {
|
|
374
|
+
footer.style.display = 'none';
|
|
375
|
+
}
|
|
376
|
+
void dialog.launch();
|
|
338
377
|
try {
|
|
339
|
-
await requestAPI('sessions/cleanup', this._serverSettings, {
|
|
378
|
+
const data = await requestAPI('sessions/cleanup', this._serverSettings, {
|
|
340
379
|
method: 'POST',
|
|
341
380
|
body: JSON.stringify({ encoded_path: session.encoded_path })
|
|
342
381
|
});
|
|
382
|
+
bar.value = 1;
|
|
383
|
+
message.textContent = `Removed ${data.removed_count} parallel session${data.removed_count === 1 ? '' : 's'}.`;
|
|
343
384
|
// Refresh so the row's extra_sessions count (and menu label) update
|
|
344
385
|
await this._fetch();
|
|
345
386
|
}
|
|
346
387
|
catch (err) {
|
|
388
|
+
bar.remove();
|
|
389
|
+
message.classList.add('jp-ClaudeSessionsPanel-cleanupError');
|
|
390
|
+
message.textContent = `Cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
347
391
|
this._showError(err);
|
|
348
392
|
}
|
|
393
|
+
finally {
|
|
394
|
+
if (footer) {
|
|
395
|
+
footer.style.display = '';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
349
398
|
}
|
|
350
399
|
// -------------------------------------------------------------- terminal
|
|
351
400
|
async _resumeInTerminal(session, forceDangerous = false) {
|
|
@@ -429,6 +478,53 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
429
478
|
});
|
|
430
479
|
}
|
|
431
480
|
}
|
|
481
|
+
/** Absolute path of the file browser's current folder; falls back to the
|
|
482
|
+
* server root when no file browser is available. */
|
|
483
|
+
_currentFolder() {
|
|
484
|
+
var _a, _b, _c;
|
|
485
|
+
const rel = ((_c = (_b = (_a = this._fileBrowser) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.path) !== null && _c !== void 0 ? _c : '').replace(/^\/+/, '');
|
|
486
|
+
return rel ? `${this._rootDir}/${rel}` : this._rootDir;
|
|
487
|
+
}
|
|
488
|
+
/** Start a brand-new claude session in the file browser's current folder.
|
|
489
|
+
* Same launch path as resuming (claude is the pty's only process via the
|
|
490
|
+
* launch-terminal endpoint) - just without --resume, and always a fresh
|
|
491
|
+
* terminal since there is no existing session to reuse.
|
|
492
|
+
*/
|
|
493
|
+
async _newSession(forceDangerous) {
|
|
494
|
+
const projectPath = this._currentFolder();
|
|
495
|
+
if (!projectPath) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const spinner = this._showLaunchSpinner();
|
|
499
|
+
try {
|
|
500
|
+
const launched = await requestAPI('launch-terminal', this._serverSettings, {
|
|
501
|
+
method: 'POST',
|
|
502
|
+
body: JSON.stringify({
|
|
503
|
+
project_path: projectPath,
|
|
504
|
+
dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
|
|
505
|
+
})
|
|
506
|
+
});
|
|
507
|
+
const widget = await this._app.commands.execute('terminal:open', {
|
|
508
|
+
name: launched.terminal_name
|
|
509
|
+
});
|
|
510
|
+
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
511
|
+
this._terminalsByPath.set(projectPath, widget);
|
|
512
|
+
this._wireTerminalDisposal(projectPath, widget);
|
|
513
|
+
this._focusTerminal(widget);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
this._showError(err);
|
|
518
|
+
}
|
|
519
|
+
finally {
|
|
520
|
+
spinner.dispose();
|
|
521
|
+
// The new session creates a project dir under ~/.claude - refresh so
|
|
522
|
+
// its row (and remote-control state) appears without waiting a poll.
|
|
523
|
+
void this._fetch().catch(() => {
|
|
524
|
+
/* a poll tick will retry; nothing actionable here */
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
432
528
|
/**
|
|
433
529
|
* Bring a terminal tab to the front AND hand it keyboard focus, so the
|
|
434
530
|
* user can start typing without an extra click. `activateById` only
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_claude_code_extension",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
4
4
|
"description": "Browse, resume, and manage your Claude Code CLI sessions from a JupyterLab side panel. One click reactivates the right terminal - no duplicate tabs, live remote-control indicator, and favourites for the projects you keep coming back to.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@jupyterlab/apputils": "^4.0.0",
|
|
60
60
|
"@jupyterlab/coreutils": "^6.0.0",
|
|
61
|
+
"@jupyterlab/filebrowser": "^4.0.0",
|
|
61
62
|
"@jupyterlab/services": "^7.0.0",
|
|
62
63
|
"@jupyterlab/settingregistry": "^4.0.0",
|
|
63
64
|
"@jupyterlab/terminal": "^4.0.0",
|
|
@@ -84,6 +84,44 @@ describe('launch spinner dismiss contract', () => {
|
|
|
84
84
|
'utf-8'
|
|
85
85
|
);
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Contract for the cleanup-parallel popup: the dialog opens with the
|
|
89
|
+
* Close button hidden while the POST is in flight (footer display
|
|
90
|
+
* 'none'), shows an indeterminate <progress> bar, then on completion
|
|
91
|
+
* fills the bar and reports success - or drops the bar and shows the
|
|
92
|
+
* error - and restores the footer so the user can dismiss.
|
|
93
|
+
*/
|
|
94
|
+
describe('cleanup popup contract', () => {
|
|
95
|
+
const cleanup = (widgetSrc.match(
|
|
96
|
+
/private async _cleanupParallel[\s\S]*?\n \}/
|
|
97
|
+
) ?? [''])[0];
|
|
98
|
+
|
|
99
|
+
it('creates a progress element in the dialog body', () => {
|
|
100
|
+
expect(cleanup).toMatch(/createElement\('progress'\)/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('hides the footer during the request and restores it in finally', () => {
|
|
104
|
+
expect(cleanup).toMatch(/footer\.style\.display = 'none'/);
|
|
105
|
+
expect(cleanup).toMatch(/finally[\s\S]*?footer\.style\.display = ''/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('fills the bar and reports the removed count on success', () => {
|
|
109
|
+
expect(cleanup).toMatch(/bar\.value = 1/);
|
|
110
|
+
expect(cleanup).toMatch(/Removed \$\{data\.removed_count\}/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('shows an error message styled with the error class on failure', () => {
|
|
114
|
+
expect(cleanup).toMatch(/catch[\s\S]*?bar\.remove\(\)/);
|
|
115
|
+
expect(cleanup).toMatch(/jp-ClaudeSessionsPanel-cleanupError/);
|
|
116
|
+
expect(cleanup).toMatch(/Cleanup failed: /);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('refreshes the session list after a successful cleanup', () => {
|
|
120
|
+
const successBlock = (cleanup.match(/try[\s\S]*?catch/) ?? [''])[0];
|
|
121
|
+
expect(successBlock).toMatch(/await this\._fetch\(\)/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
87
125
|
it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
|
|
88
126
|
expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
|
|
89
127
|
expect(widgetSrc).not.toMatch(/spinner\.resolve\(\)/);
|
package/src/icons.ts
CHANGED
|
@@ -60,6 +60,32 @@ export const shieldIcon = new LabIcon({
|
|
|
60
60
|
svgstr: shieldSvgStr
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
// Material "add" plus, same 16px/jp-icon3 treatment as the other header
|
|
64
|
+
// icons.
|
|
65
|
+
const addSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
|
|
66
|
+
<path class="jp-icon3" fill="#616161" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
|
67
|
+
</svg>`;
|
|
68
|
+
|
|
69
|
+
export const addIcon = new LabIcon({
|
|
70
|
+
name: 'jupyterlab_claude_code_extension:add',
|
|
71
|
+
svgstr: addSvgStr
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Plus with a small solid shield in the lower-right corner - the
|
|
75
|
+
// skip-permissions variant of the "new session" button, echoing the
|
|
76
|
+
// shield used on the context menu's "Resume (Skip Permissions)".
|
|
77
|
+
const addShieldSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
|
|
78
|
+
<g class="jp-icon3" fill="#616161">
|
|
79
|
+
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" transform="translate(-2,-2) scale(0.83)"/>
|
|
80
|
+
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z" transform="translate(13,12) scale(0.46)"/>
|
|
81
|
+
</g>
|
|
82
|
+
</svg>`;
|
|
83
|
+
|
|
84
|
+
export const addShieldIcon = new LabIcon({
|
|
85
|
+
name: 'jupyterlab_claude_code_extension:add-shield',
|
|
86
|
+
svgstr: addShieldSvgStr
|
|
87
|
+
});
|
|
88
|
+
|
|
63
89
|
// Funnel copied verbatim from @jupyterlab/ui-components'
|
|
64
90
|
// `search/filter.svg` - the same image the file browser's filter
|
|
65
91
|
// toggle uses. The `class="jp-icon3"` lets JupyterLab's theme drive
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
JupyterFrontEnd,
|
|
5
5
|
JupyterFrontEndPlugin
|
|
6
6
|
} from '@jupyterlab/application';
|
|
7
|
+
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
7
8
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
8
9
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
9
10
|
|
|
@@ -20,13 +21,19 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
20
21
|
'Side panel listing Claude Code sessions per project folder, with remote-control indicator, favorites, and one-click resume in a terminal.',
|
|
21
22
|
autoStart: true,
|
|
22
23
|
requires: [ILabShell],
|
|
23
|
-
optional: [
|
|
24
|
+
optional: [
|
|
25
|
+
ILayoutRestorer,
|
|
26
|
+
ISettingRegistry,
|
|
27
|
+
ITerminalTracker,
|
|
28
|
+
IDefaultFileBrowser
|
|
29
|
+
],
|
|
24
30
|
activate: async (
|
|
25
31
|
app: JupyterFrontEnd,
|
|
26
32
|
labShell: ILabShell,
|
|
27
33
|
restorer: ILayoutRestorer | null,
|
|
28
34
|
settingRegistry: ISettingRegistry | null,
|
|
29
|
-
terminalTracker: ITerminalTracker | null
|
|
35
|
+
terminalTracker: ITerminalTracker | null,
|
|
36
|
+
fileBrowser: IDefaultFileBrowser | null
|
|
30
37
|
) => {
|
|
31
38
|
const settings = app.serviceManager.serverSettings;
|
|
32
39
|
|
|
@@ -51,7 +58,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
51
58
|
const widget = new ClaudeCodeSessionsWidget(
|
|
52
59
|
app,
|
|
53
60
|
status.root_dir || '',
|
|
54
|
-
terminalTracker
|
|
61
|
+
terminalTracker,
|
|
62
|
+
fileBrowser
|
|
55
63
|
);
|
|
56
64
|
|
|
57
65
|
// Read the sidebar setting before docking so we add the widget to the
|
package/src/types.ts
CHANGED
|
@@ -53,7 +53,8 @@ export interface ICleanupResponse {
|
|
|
53
53
|
|
|
54
54
|
export interface ILaunchTerminalRequest {
|
|
55
55
|
project_path: string;
|
|
56
|
-
|
|
56
|
+
/** Omit to start a brand-new claude session instead of resuming one. */
|
|
57
|
+
session_id?: string;
|
|
57
58
|
dangerously_skip_permissions?: boolean;
|
|
58
59
|
}
|
|
59
60
|
|
package/src/widget.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
showDialog
|
|
7
7
|
} from '@jupyterlab/apputils';
|
|
8
8
|
import { ServerConnection } from '@jupyterlab/services';
|
|
9
|
+
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
9
10
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
10
11
|
import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
11
12
|
import { CommandRegistry } from '@lumino/commands';
|
|
@@ -14,6 +15,8 @@ import { Message } from '@lumino/messaging';
|
|
|
14
15
|
|
|
15
16
|
import { requestAPI } from './request';
|
|
16
17
|
import {
|
|
18
|
+
addIcon,
|
|
19
|
+
addShieldIcon,
|
|
17
20
|
claudeIcon,
|
|
18
21
|
filterIcon,
|
|
19
22
|
refreshIcon,
|
|
@@ -101,13 +104,15 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
101
104
|
constructor(
|
|
102
105
|
app: JupyterFrontEnd,
|
|
103
106
|
rootDir: string,
|
|
104
|
-
terminalTracker: ITerminalTracker | null = null
|
|
107
|
+
terminalTracker: ITerminalTracker | null = null,
|
|
108
|
+
fileBrowser: IDefaultFileBrowser | null = null
|
|
105
109
|
) {
|
|
106
110
|
super();
|
|
107
111
|
this._app = app;
|
|
108
112
|
this._serverSettings = app.serviceManager.serverSettings;
|
|
109
113
|
this._rootDir = rootDir.replace(/\/+$/, '');
|
|
110
114
|
this._terminalTracker = terminalTracker;
|
|
115
|
+
this._fileBrowser = fileBrowser;
|
|
111
116
|
|
|
112
117
|
this.id = 'jupyterlab-claude-code-extension';
|
|
113
118
|
this.title.icon = claudeIcon;
|
|
@@ -185,6 +190,21 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
185
190
|
title.textContent = 'Claude Code Sessions';
|
|
186
191
|
header.appendChild(title);
|
|
187
192
|
|
|
193
|
+
const newBtn = document.createElement('button');
|
|
194
|
+
newBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
195
|
+
newBtn.title = 'New Claude session in the current folder';
|
|
196
|
+
addIcon.element({ container: newBtn });
|
|
197
|
+
newBtn.addEventListener('click', () => void this._newSession(false));
|
|
198
|
+
header.appendChild(newBtn);
|
|
199
|
+
|
|
200
|
+
const newSkipBtn = document.createElement('button');
|
|
201
|
+
newSkipBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
202
|
+
newSkipBtn.title =
|
|
203
|
+
'New Claude session in the current folder (Skip Permissions)';
|
|
204
|
+
addShieldIcon.element({ container: newSkipBtn });
|
|
205
|
+
newSkipBtn.addEventListener('click', () => void this._newSession(true));
|
|
206
|
+
header.appendChild(newSkipBtn);
|
|
207
|
+
|
|
188
208
|
const filterBtn = document.createElement('button');
|
|
189
209
|
filterBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
190
210
|
filterBtn.title = 'Filter sessions';
|
|
@@ -408,8 +428,41 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
408
428
|
}
|
|
409
429
|
|
|
410
430
|
private async _cleanupParallel(session: ISession): Promise<void> {
|
|
431
|
+
const body = new Widget();
|
|
432
|
+
body.node.className = 'jp-ClaudeSessionsPanel-cleanupBody';
|
|
433
|
+
|
|
434
|
+
const message = document.createElement('div');
|
|
435
|
+
message.className = 'jp-ClaudeSessionsPanel-cleanupMessage';
|
|
436
|
+
const count = session.extra_sessions;
|
|
437
|
+
message.textContent = `Removing ${count} parallel session${
|
|
438
|
+
count === 1 ? '' : 's'
|
|
439
|
+
}...`;
|
|
440
|
+
body.node.appendChild(message);
|
|
441
|
+
|
|
442
|
+
// No `value` attribute -> indeterminate (animated) while the request is
|
|
443
|
+
// in flight; set to max on completion so the bar reads as finished.
|
|
444
|
+
const bar = document.createElement('progress');
|
|
445
|
+
bar.className = 'jp-ClaudeSessionsPanel-cleanupProgress';
|
|
446
|
+
bar.max = 1;
|
|
447
|
+
body.node.appendChild(bar);
|
|
448
|
+
|
|
449
|
+
const dialog = new Dialog<unknown>({
|
|
450
|
+
title: 'Clean Up Parallel Sessions',
|
|
451
|
+
body,
|
|
452
|
+
buttons: [Dialog.okButton({ label: 'Close' })]
|
|
453
|
+
});
|
|
454
|
+
// Hide the Close button while work is in progress; restore it once the
|
|
455
|
+
// outcome (success or error) is shown so the user dismisses the popup.
|
|
456
|
+
const footer = dialog.node.querySelector(
|
|
457
|
+
'.jp-Dialog-footer'
|
|
458
|
+
) as HTMLElement | null;
|
|
459
|
+
if (footer) {
|
|
460
|
+
footer.style.display = 'none';
|
|
461
|
+
}
|
|
462
|
+
void dialog.launch();
|
|
463
|
+
|
|
411
464
|
try {
|
|
412
|
-
await requestAPI<ICleanupResponse>(
|
|
465
|
+
const data = await requestAPI<ICleanupResponse>(
|
|
413
466
|
'sessions/cleanup',
|
|
414
467
|
this._serverSettings,
|
|
415
468
|
{
|
|
@@ -417,10 +470,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
417
470
|
body: JSON.stringify({ encoded_path: session.encoded_path })
|
|
418
471
|
}
|
|
419
472
|
);
|
|
473
|
+
bar.value = 1;
|
|
474
|
+
message.textContent = `Removed ${data.removed_count} parallel session${
|
|
475
|
+
data.removed_count === 1 ? '' : 's'
|
|
476
|
+
}.`;
|
|
420
477
|
// Refresh so the row's extra_sessions count (and menu label) update
|
|
421
478
|
await this._fetch();
|
|
422
479
|
} catch (err) {
|
|
480
|
+
bar.remove();
|
|
481
|
+
message.classList.add('jp-ClaudeSessionsPanel-cleanupError');
|
|
482
|
+
message.textContent = `Cleanup failed: ${
|
|
483
|
+
err instanceof Error ? err.message : String(err)
|
|
484
|
+
}`;
|
|
423
485
|
this._showError(err);
|
|
486
|
+
} finally {
|
|
487
|
+
if (footer) {
|
|
488
|
+
footer.style.display = '';
|
|
489
|
+
}
|
|
424
490
|
}
|
|
425
491
|
}
|
|
426
492
|
|
|
@@ -522,6 +588,57 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
522
588
|
}
|
|
523
589
|
}
|
|
524
590
|
|
|
591
|
+
/** Absolute path of the file browser's current folder; falls back to the
|
|
592
|
+
* server root when no file browser is available. */
|
|
593
|
+
private _currentFolder(): string {
|
|
594
|
+
const rel = (this._fileBrowser?.model?.path ?? '').replace(/^\/+/, '');
|
|
595
|
+
return rel ? `${this._rootDir}/${rel}` : this._rootDir;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Start a brand-new claude session in the file browser's current folder.
|
|
599
|
+
* Same launch path as resuming (claude is the pty's only process via the
|
|
600
|
+
* launch-terminal endpoint) - just without --resume, and always a fresh
|
|
601
|
+
* terminal since there is no existing session to reuse.
|
|
602
|
+
*/
|
|
603
|
+
private async _newSession(forceDangerous: boolean): Promise<void> {
|
|
604
|
+
const projectPath = this._currentFolder();
|
|
605
|
+
if (!projectPath) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const spinner = this._showLaunchSpinner();
|
|
609
|
+
try {
|
|
610
|
+
const launched = await requestAPI<ILaunchTerminalResponse>(
|
|
611
|
+
'launch-terminal',
|
|
612
|
+
this._serverSettings,
|
|
613
|
+
{
|
|
614
|
+
method: 'POST',
|
|
615
|
+
body: JSON.stringify({
|
|
616
|
+
project_path: projectPath,
|
|
617
|
+
dangerously_skip_permissions:
|
|
618
|
+
forceDangerous || this._dangerouslySkip
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
);
|
|
622
|
+
const widget: any = await this._app.commands.execute('terminal:open', {
|
|
623
|
+
name: launched.terminal_name
|
|
624
|
+
});
|
|
625
|
+
if (widget?.id) {
|
|
626
|
+
this._terminalsByPath.set(projectPath, widget);
|
|
627
|
+
this._wireTerminalDisposal(projectPath, widget);
|
|
628
|
+
this._focusTerminal(widget);
|
|
629
|
+
}
|
|
630
|
+
} catch (err) {
|
|
631
|
+
this._showError(err);
|
|
632
|
+
} finally {
|
|
633
|
+
spinner.dispose();
|
|
634
|
+
// The new session creates a project dir under ~/.claude - refresh so
|
|
635
|
+
// its row (and remote-control state) appears without waiting a poll.
|
|
636
|
+
void this._fetch().catch(() => {
|
|
637
|
+
/* a poll tick will retry; nothing actionable here */
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
525
642
|
/**
|
|
526
643
|
* Bring a terminal tab to the front AND hand it keyboard focus, so the
|
|
527
644
|
* user can start typing without an extra click. `activateById` only
|
|
@@ -1146,6 +1263,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1146
1263
|
private _pollHandle: number | null = null;
|
|
1147
1264
|
private readonly _removingPaths: Set<string> = new Set();
|
|
1148
1265
|
private readonly _terminalTracker: ITerminalTracker | null;
|
|
1266
|
+
private readonly _fileBrowser: IDefaultFileBrowser | null;
|
|
1149
1267
|
private readonly _terminalsByPath: Map<string, any> = new Map();
|
|
1150
1268
|
private readonly _pendingByPath: Map<string, Promise<void>> = new Map();
|
|
1151
1269
|
private readonly _rootDir: string;
|
package/style/base.css
CHANGED
|
@@ -261,6 +261,24 @@
|
|
|
261
261
|
border-width: 3px;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
.jp-ClaudeSessionsPanel-cleanupBody {
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
align-items: center;
|
|
268
|
+
justify-content: center;
|
|
269
|
+
gap: 12px;
|
|
270
|
+
padding: 12px 8px;
|
|
271
|
+
min-width: 280px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.jp-ClaudeSessionsPanel-cleanupProgress {
|
|
275
|
+
width: 100%;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.jp-ClaudeSessionsPanel-cleanupError {
|
|
279
|
+
color: var(--jp-error-color1);
|
|
280
|
+
}
|
|
281
|
+
|
|
264
282
|
.jp-ClaudeSessionsPanel-row.jp-mod-busy {
|
|
265
283
|
opacity: 0.55;
|
|
266
284
|
pointer-events: none;
|