jupyterlab_claude_code_extension 1.0.47 → 1.0.53

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/README.md CHANGED
@@ -8,21 +8,20 @@
8
8
  [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
9
  [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
10
10
 
11
- Manage Claude Code CLI sessions from inside JupyterLab. A left-sidebar panel lists every project under `~/.claude/projects/` deduplicated to one row per folder, marks live remote-control sessions with a green dot, and lets you jump back into any session by opening (or reactivating) a terminal pwd'd to that project and auto-running `claude --resume <id>`.
11
+ Browse, resume, and manage your Claude Code sessions from a JupyterLab side panel. One click reactivates the right terminal, no duplicate tabs, with a live indicator showing which sessions are currently active.
12
12
 
13
13
  ![Claude Code Sessions panel](.resources/screenshot.png)
14
14
 
15
15
  ## Features
16
16
 
17
- - **Three-section side panel** - Favourites, Recent (top 10 by activity), and All. Each section scrolls independently; Favourites disappears when empty
18
- - **Live remote-control indicator** - green dot on rows whose `~/.claude/sessions/<pid>.json` is alive (verified via `os.kill(pid, 0)`)
19
- - **One-click resume** - click a row to find an existing terminal pwd'd to that project (queried server-side from the pty's process tree) and reactivate its tab; only spawns a fresh terminal if none matches. Concurrent rapid clicks are coalesced
20
- - **Smart name resolution** - shows the user-set `/rename` name when available; auto-detected names (volatile across the same `sessionId` or 3+ token lowercase-kebab) fall back to the folder basename. Toggle the behaviour via the `resolveSessionNames` setting
21
- - **Path-segment disambiguation** - when two sessions share the same display name, the row reveals the minimum number of trailing path segments needed for each to be unique
22
- - **Favourites** - star a session via the right-click menu; persisted server-side at `~/.claude/jupyterlab_claude_code_extension.json`
23
- - **Layout restorer** - panel visibility persists across JupyterLab reloads
24
- - **Auto-disabled** when the `claude` binary is not on `PATH`
25
- - **Hover tooltip** with relative path (vs JL root), last activity, message count, branch, first prompt, session id
17
+ - **Three-section side panel** - Favourites, Recent, and All projects, each scrolling independently
18
+ - **Live indicator** - a green dot marks sessions that are currently running somewhere
19
+ - **One-click resume** - click a row to jump back into that session in a terminal. If a terminal for the project is already open, it's reused instead of duplicated
20
+ - **Favourites** - star projects you keep coming back to via the right-click menu
21
+ - **Search** - fuzzy filter at the top of the panel
22
+ - **Presentation modes** - label rows by session name, folder name, or path relative to the JupyterLab root
23
+ - **Hover tooltip** with project path, last activity, message count, branch, and session id
24
+ - **Auto-disabled** when the Claude Code CLI is not installed
26
25
 
27
26
  ## Requirements
28
27
 
package/lib/index.js CHANGED
@@ -26,10 +26,17 @@ const plugin = {
26
26
  return;
27
27
  }
28
28
  const widget = new ClaudeCodeSessionsWidget(app, status.root_dir || '', terminalTracker);
29
- labShell.add(widget, 'left', { rank: 600 });
29
+ // Read the sidebar setting before docking so we add the widget to the
30
+ // user's preferred side on first paint. Default to left.
31
+ let currentSidebar = 'left';
30
32
  if (settingRegistry) {
31
33
  try {
32
34
  const settings = await settingRegistry.load(PLUGIN_ID);
35
+ const initialSidebar = settings.get('sidebar').composite;
36
+ if (initialSidebar === 'left' || initialSidebar === 'right') {
37
+ currentSidebar = initialSidebar;
38
+ }
39
+ labShell.add(widget, currentSidebar, { rank: 600 });
33
40
  const apply = () => {
34
41
  const mode = settings.get('presentationMode').composite;
35
42
  if (mode === 'session' || mode === 'folder' || mode === 'path') {
@@ -42,14 +49,26 @@ const plugin = {
42
49
  const dangerous = settings.get('dangerouslySkipPermissions')
43
50
  .composite;
44
51
  widget.setDangerouslySkipPermissions(!!dangerous);
52
+ const sidebar = settings.get('sidebar').composite;
53
+ if ((sidebar === 'left' || sidebar === 'right') &&
54
+ sidebar !== currentSidebar) {
55
+ currentSidebar = sidebar;
56
+ // Lumino re-parents the widget cleanly when add() is called
57
+ // against a different area.
58
+ labShell.add(widget, sidebar, { rank: 600 });
59
+ }
45
60
  };
46
61
  apply();
47
62
  settings.changed.connect(apply);
48
63
  }
49
64
  catch (err) {
50
65
  console.warn('[jupyterlab_claude_code_extension] failed to load settings; using defaults', err);
66
+ labShell.add(widget, currentSidebar, { rank: 600 });
51
67
  }
52
68
  }
69
+ else {
70
+ labShell.add(widget, currentSidebar, { rank: 600 });
71
+ }
53
72
  // Register with the layout restorer so JL remembers whether the panel
54
73
  // was active/visible across browser reloads and restarts.
55
74
  if (restorer) {
package/lib/types.d.ts CHANGED
@@ -3,6 +3,7 @@ export interface ISession {
3
3
  encoded_path: string;
4
4
  session_id: string;
5
5
  name: string;
6
+ name_source: 'rename' | 'basename';
6
7
  summary: string;
7
8
  first_prompt: string;
8
9
  message_count: number;
package/lib/widget.d.ts CHANGED
@@ -40,12 +40,23 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
40
40
  private _doResumeInTerminal;
41
41
  private _findTerminalForCwd;
42
42
  private _showCloseExistingDialog;
43
+ /** Show a modal with a spinner while the terminal is being launched. The
44
+ * caller must dismiss it via ``.resolve()`` once the work is done.
45
+ */
46
+ private _showLaunchSpinner;
43
47
  private _wireTerminalDisposal;
44
48
  /** Apply the presentation-mode setting (path-segment disambiguation is
45
49
  * handled separately in ``_disambiguate``). */
46
50
  private _displayName;
47
51
  private _basename;
48
- /** Walk path tails until every name in a colliding group is unique. */
52
+ /** Walk path tails until every name in a colliding group is unique.
53
+ *
54
+ * User-set rename names (``name_source === 'rename'``) survive
55
+ * disambiguation untouched: a Claude ``/rename`` should never be rolled
56
+ * back to a path tail just because some other row happens to share its
57
+ * folder basename. Basename-derived rows in the same group get the path
58
+ * tail suffix, picked so it stays distinct from every rename label too.
59
+ */
49
60
  private _disambiguate;
50
61
  private _render;
51
62
  private _renderSection;
package/lib/widget.js CHANGED
@@ -338,21 +338,29 @@ export class ClaudeCodeSessionsWidget extends Widget {
338
338
  // as the pty's only process (no shell). Server-side endpoint calls
339
339
  // terminal_manager.create(shell_command=[claude, --resume, sid], cwd=...)
340
340
  // and returns the terminal name; we then attach JL's standard widget
341
- // via terminal:open. When claude exits, the tab closes.
342
- const launched = await requestAPI('launch-terminal', this._serverSettings, {
343
- method: 'POST',
344
- body: JSON.stringify({
345
- project_path: session.project_path,
346
- session_id: session.session_id,
347
- dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
348
- })
349
- });
350
- const widget = await this._app.commands.execute('terminal:open', {
351
- name: launched.terminal_name
352
- });
353
- if (widget === null || widget === void 0 ? void 0 : widget.id) {
354
- this._terminalsByPath.set(session.project_path, widget);
355
- this._wireTerminalDisposal(session.project_path, widget);
341
+ // via terminal:open. When claude exits, the tab closes. The launch
342
+ // RPC + the WebSocket-resize waiter on the server can take a few
343
+ // seconds, so show a modal spinner for visual feedback.
344
+ const spinner = this._showLaunchSpinner(`Opening ${this._lookupName(session)}...`);
345
+ try {
346
+ const launched = await requestAPI('launch-terminal', this._serverSettings, {
347
+ method: 'POST',
348
+ body: JSON.stringify({
349
+ project_path: session.project_path,
350
+ session_id: session.session_id,
351
+ dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
352
+ })
353
+ });
354
+ const widget = await this._app.commands.execute('terminal:open', {
355
+ name: launched.terminal_name
356
+ });
357
+ if (widget === null || widget === void 0 ? void 0 : widget.id) {
358
+ this._terminalsByPath.set(session.project_path, widget);
359
+ this._wireTerminalDisposal(session.project_path, widget);
360
+ }
361
+ }
362
+ finally {
363
+ spinner.resolve();
356
364
  }
357
365
  }
358
366
  catch (err) {
@@ -407,6 +415,30 @@ export class ClaudeCodeSessionsWidget extends Widget {
407
415
  buttons: [Dialog.okButton({ label: 'OK' })]
408
416
  });
409
417
  }
418
+ /** Show a modal with a spinner while the terminal is being launched. The
419
+ * caller must dismiss it via ``.resolve()`` once the work is done.
420
+ */
421
+ _showLaunchSpinner(label) {
422
+ const body = new Widget();
423
+ body.node.className = 'jp-ClaudeSessionsPanel-launchOverlay';
424
+ const spinner = document.createElement('div');
425
+ spinner.className =
426
+ 'jp-claude-sessions-panel-spinner jp-ClaudeSessionsPanel-launchSpinner';
427
+ body.node.appendChild(spinner);
428
+ const text = document.createElement('div');
429
+ text.className = 'jp-ClaudeSessionsPanel-launchLabel';
430
+ text.textContent = label;
431
+ body.node.appendChild(text);
432
+ const dialog = new Dialog({
433
+ title: 'Opening Claude Code session',
434
+ body,
435
+ buttons: [Dialog.cancelButton({ label: 'Run in background' })]
436
+ });
437
+ // launch() returns a Promise we don't await - we resolve programmatically
438
+ // when the spawn completes (or errors).
439
+ void dialog.launch();
440
+ return dialog;
441
+ }
410
442
  _wireTerminalDisposal(projectPath, widget) {
411
443
  var _a;
412
444
  if (!((_a = widget === null || widget === void 0 ? void 0 : widget.disposed) === null || _a === void 0 ? void 0 : _a.connect)) {
@@ -440,7 +472,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
440
472
  const parts = p.split('/').filter(Boolean);
441
473
  return parts[parts.length - 1] || '';
442
474
  }
443
- /** Walk path tails until every name in a colliding group is unique. */
475
+ /** Walk path tails until every name in a colliding group is unique.
476
+ *
477
+ * User-set rename names (``name_source === 'rename'``) survive
478
+ * disambiguation untouched: a Claude ``/rename`` should never be rolled
479
+ * back to a path tail just because some other row happens to share its
480
+ * folder basename. Basename-derived rows in the same group get the path
481
+ * tail suffix, picked so it stays distinct from every rename label too.
482
+ */
444
483
  _disambiguate(rows) {
445
484
  var _a;
446
485
  const out = new Map();
@@ -454,20 +493,38 @@ export class ClaudeCodeSessionsWidget extends Widget {
454
493
  out.set(group[0].project_path, name);
455
494
  continue;
456
495
  }
457
- const segs = group.map(r => r.project_path.split('/').filter(Boolean));
496
+ const renames = group.filter(r => r.name_source === 'rename');
497
+ const basenames = group.filter(r => r.name_source !== 'rename');
498
+ // Rename rows win unchanged. The ``taken`` set guards basename
499
+ // disambiguation from colliding back into a rename's label.
500
+ const taken = new Set();
501
+ for (const r of renames) {
502
+ out.set(r.project_path, r.name);
503
+ taken.add(r.name);
504
+ }
505
+ if (basenames.length === 0) {
506
+ continue;
507
+ }
508
+ // Walk path tails for basename rows: tail must be unique among
509
+ // basenames AND not equal to any rename label already taken.
510
+ const segs = basenames.map(r => r.project_path.split('/').filter(Boolean));
458
511
  const max = Math.max(...segs.map(s => s.length));
459
512
  let depth = 1;
513
+ let resolved = false;
460
514
  while (depth <= max) {
461
515
  const tails = segs.map(s => s.slice(-depth).join('/'));
462
- if (new Set(tails).size === tails.length) {
463
- group.forEach((r, i) => out.set(r.project_path, tails[i]));
516
+ const unique = new Set(tails).size === tails.length;
517
+ const noConflict = tails.every(t => !taken.has(t));
518
+ if (unique && noConflict) {
519
+ basenames.forEach((r, i) => out.set(r.project_path, tails[i]));
520
+ resolved = true;
464
521
  break;
465
522
  }
466
523
  depth += 1;
467
524
  }
468
- if (!out.has(group[0].project_path)) {
525
+ if (!resolved) {
469
526
  // Fallback to absolute path
470
- group.forEach(r => out.set(r.project_path, r.project_path));
527
+ basenames.forEach(r => out.set(r.project_path, r.project_path));
471
528
  }
472
529
  }
473
530
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.0.47",
3
+ "version": "1.0.53",
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",
package/src/index.ts CHANGED
@@ -53,11 +53,20 @@ const plugin: JupyterFrontEndPlugin<void> = {
53
53
  status.root_dir || '',
54
54
  terminalTracker
55
55
  );
56
- labShell.add(widget, 'left', { rank: 600 });
56
+
57
+ // Read the sidebar setting before docking so we add the widget to the
58
+ // user's preferred side on first paint. Default to left.
59
+ let currentSidebar: 'left' | 'right' = 'left';
57
60
 
58
61
  if (settingRegistry) {
59
62
  try {
60
63
  const settings = await settingRegistry.load(PLUGIN_ID);
64
+ const initialSidebar = settings.get('sidebar').composite as string;
65
+ if (initialSidebar === 'left' || initialSidebar === 'right') {
66
+ currentSidebar = initialSidebar;
67
+ }
68
+ labShell.add(widget, currentSidebar, { rank: 600 });
69
+
61
70
  const apply = (): void => {
62
71
  const mode = settings.get('presentationMode').composite as string;
63
72
  if (mode === 'session' || mode === 'folder' || mode === 'path') {
@@ -70,6 +79,16 @@ const plugin: JupyterFrontEndPlugin<void> = {
70
79
  const dangerous = settings.get('dangerouslySkipPermissions')
71
80
  .composite as boolean;
72
81
  widget.setDangerouslySkipPermissions(!!dangerous);
82
+ const sidebar = settings.get('sidebar').composite as string;
83
+ if (
84
+ (sidebar === 'left' || sidebar === 'right') &&
85
+ sidebar !== currentSidebar
86
+ ) {
87
+ currentSidebar = sidebar;
88
+ // Lumino re-parents the widget cleanly when add() is called
89
+ // against a different area.
90
+ labShell.add(widget, sidebar, { rank: 600 });
91
+ }
73
92
  };
74
93
  apply();
75
94
  settings.changed.connect(apply);
@@ -78,7 +97,10 @@ const plugin: JupyterFrontEndPlugin<void> = {
78
97
  '[jupyterlab_claude_code_extension] failed to load settings; using defaults',
79
98
  err
80
99
  );
100
+ labShell.add(widget, currentSidebar, { rank: 600 });
81
101
  }
102
+ } else {
103
+ labShell.add(widget, currentSidebar, { rank: 600 });
82
104
  }
83
105
 
84
106
  // Register with the layout restorer so JL remembers whether the panel
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@ export interface ISession {
3
3
  encoded_path: string;
4
4
  session_id: string;
5
5
  name: string;
6
+ name_source: 'rename' | 'basename';
6
7
  summary: string;
7
8
  first_prompt: string;
8
9
  message_count: number;
package/src/widget.ts CHANGED
@@ -415,26 +415,35 @@ export class ClaudeCodeSessionsWidget extends Widget {
415
415
  // as the pty's only process (no shell). Server-side endpoint calls
416
416
  // terminal_manager.create(shell_command=[claude, --resume, sid], cwd=...)
417
417
  // and returns the terminal name; we then attach JL's standard widget
418
- // via terminal:open. When claude exits, the tab closes.
419
- const launched = await requestAPI<ILaunchTerminalResponse>(
420
- 'launch-terminal',
421
- this._serverSettings,
422
- {
423
- method: 'POST',
424
- body: JSON.stringify({
425
- project_path: session.project_path,
426
- session_id: session.session_id,
427
- dangerously_skip_permissions:
428
- forceDangerous || this._dangerouslySkip
429
- })
430
- }
418
+ // via terminal:open. When claude exits, the tab closes. The launch
419
+ // RPC + the WebSocket-resize waiter on the server can take a few
420
+ // seconds, so show a modal spinner for visual feedback.
421
+ const spinner = this._showLaunchSpinner(
422
+ `Opening ${this._lookupName(session)}...`
431
423
  );
432
- const widget: any = await this._app.commands.execute('terminal:open', {
433
- name: launched.terminal_name
434
- });
435
- if (widget?.id) {
436
- this._terminalsByPath.set(session.project_path, widget);
437
- this._wireTerminalDisposal(session.project_path, widget);
424
+ try {
425
+ const launched = await requestAPI<ILaunchTerminalResponse>(
426
+ 'launch-terminal',
427
+ this._serverSettings,
428
+ {
429
+ method: 'POST',
430
+ body: JSON.stringify({
431
+ project_path: session.project_path,
432
+ session_id: session.session_id,
433
+ dangerously_skip_permissions:
434
+ forceDangerous || this._dangerouslySkip
435
+ })
436
+ }
437
+ );
438
+ const widget: any = await this._app.commands.execute('terminal:open', {
439
+ name: launched.terminal_name
440
+ });
441
+ if (widget?.id) {
442
+ this._terminalsByPath.set(session.project_path, widget);
443
+ this._wireTerminalDisposal(session.project_path, widget);
444
+ }
445
+ } finally {
446
+ spinner.resolve();
438
447
  }
439
448
  } catch (err) {
440
449
  this._showError(err);
@@ -493,6 +502,34 @@ export class ClaudeCodeSessionsWidget extends Widget {
493
502
  });
494
503
  }
495
504
 
505
+ /** Show a modal with a spinner while the terminal is being launched. The
506
+ * caller must dismiss it via ``.resolve()`` once the work is done.
507
+ */
508
+ private _showLaunchSpinner(label: string): Dialog<unknown> {
509
+ const body = new Widget();
510
+ body.node.className = 'jp-ClaudeSessionsPanel-launchOverlay';
511
+
512
+ const spinner = document.createElement('div');
513
+ spinner.className =
514
+ 'jp-claude-sessions-panel-spinner jp-ClaudeSessionsPanel-launchSpinner';
515
+ body.node.appendChild(spinner);
516
+
517
+ const text = document.createElement('div');
518
+ text.className = 'jp-ClaudeSessionsPanel-launchLabel';
519
+ text.textContent = label;
520
+ body.node.appendChild(text);
521
+
522
+ const dialog = new Dialog<unknown>({
523
+ title: 'Opening Claude Code session',
524
+ body,
525
+ buttons: [Dialog.cancelButton({ label: 'Run in background' })]
526
+ });
527
+ // launch() returns a Promise we don't await - we resolve programmatically
528
+ // when the spawn completes (or errors).
529
+ void dialog.launch();
530
+ return dialog;
531
+ }
532
+
496
533
  private _wireTerminalDisposal(projectPath: string, widget: any): void {
497
534
  if (!widget?.disposed?.connect) {
498
535
  return;
@@ -529,7 +566,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
529
566
  return parts[parts.length - 1] || '';
530
567
  }
531
568
 
532
- /** Walk path tails until every name in a colliding group is unique. */
569
+ /** Walk path tails until every name in a colliding group is unique.
570
+ *
571
+ * User-set rename names (``name_source === 'rename'``) survive
572
+ * disambiguation untouched: a Claude ``/rename`` should never be rolled
573
+ * back to a path tail just because some other row happens to share its
574
+ * folder basename. Basename-derived rows in the same group get the path
575
+ * tail suffix, picked so it stays distinct from every rename label too.
576
+ */
533
577
  private _disambiguate(rows: ISession[]): Map<string, string> {
534
578
  const out = new Map<string, string>();
535
579
  const groups = new Map<string, ISession[]>();
@@ -542,20 +586,43 @@ export class ClaudeCodeSessionsWidget extends Widget {
542
586
  out.set(group[0].project_path, name);
543
587
  continue;
544
588
  }
545
- const segs = group.map(r => r.project_path.split('/').filter(Boolean));
589
+
590
+ const renames = group.filter(r => r.name_source === 'rename');
591
+ const basenames = group.filter(r => r.name_source !== 'rename');
592
+
593
+ // Rename rows win unchanged. The ``taken`` set guards basename
594
+ // disambiguation from colliding back into a rename's label.
595
+ const taken = new Set<string>();
596
+ for (const r of renames) {
597
+ out.set(r.project_path, r.name);
598
+ taken.add(r.name);
599
+ }
600
+ if (basenames.length === 0) {
601
+ continue;
602
+ }
603
+
604
+ // Walk path tails for basename rows: tail must be unique among
605
+ // basenames AND not equal to any rename label already taken.
606
+ const segs = basenames.map(r =>
607
+ r.project_path.split('/').filter(Boolean)
608
+ );
546
609
  const max = Math.max(...segs.map(s => s.length));
547
610
  let depth = 1;
611
+ let resolved = false;
548
612
  while (depth <= max) {
549
613
  const tails = segs.map(s => s.slice(-depth).join('/'));
550
- if (new Set(tails).size === tails.length) {
551
- group.forEach((r, i) => out.set(r.project_path, tails[i]));
614
+ const unique = new Set(tails).size === tails.length;
615
+ const noConflict = tails.every(t => !taken.has(t));
616
+ if (unique && noConflict) {
617
+ basenames.forEach((r, i) => out.set(r.project_path, tails[i]));
618
+ resolved = true;
552
619
  break;
553
620
  }
554
621
  depth += 1;
555
622
  }
556
- if (!out.has(group[0].project_path)) {
623
+ if (!resolved) {
557
624
  // Fallback to absolute path
558
- group.forEach(r => out.set(r.project_path, r.project_path));
625
+ basenames.forEach(r => out.set(r.project_path, r.project_path));
559
626
  }
560
627
  }
561
628
  return out;
package/style/base.css CHANGED
@@ -237,6 +237,29 @@
237
237
  display: inline-block;
238
238
  }
239
239
 
240
+ .jp-ClaudeSessionsPanel-launchOverlay {
241
+ display: flex;
242
+ flex-direction: column;
243
+ align-items: center;
244
+ justify-content: center;
245
+ gap: 12px;
246
+ padding: 12px 8px;
247
+ min-width: 240px;
248
+ }
249
+
250
+ .jp-ClaudeSessionsPanel-launchSpinner {
251
+ width: 28px;
252
+ height: 28px;
253
+ border-width: 3px;
254
+ }
255
+
256
+ .jp-ClaudeSessionsPanel-launchLabel {
257
+ font-size: var(--jp-ui-font-size1);
258
+ color: var(--jp-ui-font-color1);
259
+ text-align: center;
260
+ word-break: break-word;
261
+ }
262
+
240
263
  .jp-ClaudeSessionsPanel-row.jp-mod-busy {
241
264
  opacity: 0.55;
242
265
  pointer-events: none;