jupyterlab_ai_code_assistants_extension 1.2.99 → 1.2.115
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 +17 -0
- package/lib/core/panel.d.ts +12 -6
- package/lib/core/panel.js +79 -36
- package/lib/core/popup.js +13 -13
- package/lib/core/terminals.d.ts +16 -8
- package/lib/core/terminals.js +29 -11
- package/lib/index.js +3 -3
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +14 -6
- package/src/__tests__/launcher.spec.ts +34 -3
- package/src/__tests__/panel.spec.ts +69 -25
- package/src/__tests__/popup.spec.ts +15 -2
- package/src/__tests__/terminals.spec.ts +53 -1
- package/src/core/panel.ts +83 -40
- package/src/core/popup.ts +13 -13
- package/src/core/terminals.ts +36 -11
- package/src/index.ts +3 -3
- package/style/base.css +22 -8
package/README.md
CHANGED
|
@@ -76,6 +76,23 @@ pip install jupyterlab_ai_code_assistants_extension
|
|
|
76
76
|
> [!WARNING]
|
|
77
77
|
> `package.json` pins `webpack: 5.106.0` and `chalk: 4.1.2` in both `resolutions` and `overrides`. Do not remove these. webpack `>= 5.106.1` changed its module-federation share identifier format and crashes the unmaintained `license-webpack-plugin` (`split('=')[1].trim()`) that `@jupyterlab/builder` injects into every production build; the duplicate `chalk@2.4.2` pulled by `duplicate-package-checker-webpack-plugin` crashes on Node 24+ in the build-isolation install. Without the pins, `make publish` and CI fail on `python -m build`.
|
|
78
78
|
|
|
79
|
+
## Agent skill
|
|
80
|
+
|
|
81
|
+
The wheel ships a session-operations skill for coding agents at `share/jupyter/agents/skills/jupyterlab-ai-code-assistants-extension/SKILL.md`. It covers finding, moving, splitting, renaming and promoting sessions, copying a session into another project under a fresh id, the task-list environment variables, and running Claude Code against Kimi K3 or DeepSeek.
|
|
82
|
+
|
|
83
|
+
After `pip install`, with the Python that runs the lab:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
mkdir -p ~/.agents/skills
|
|
87
|
+
ln -s "$(python -c 'import sys; print(sys.prefix)')/share/jupyter/agents/skills/jupyterlab-ai-code-assistants-extension" ~/.agents/skills/jupyterlab-ai-code-assistants-extension
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
From a clone:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
ln -s "$PWD/.agents/skills/jupyterlab-ai-code-assistants-extension" ~/.claude/skills/jupyterlab-ai-code-assistants-extension
|
|
94
|
+
```
|
|
95
|
+
|
|
79
96
|
## Migrating from the standalone extensions
|
|
80
97
|
|
|
81
98
|
This package supersedes `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension`. On first run it carries their settings and favourites over automatically, and it never replaces a setting you have already saved in this extension; uninstall the standalones once you are ready:
|
package/lib/core/panel.d.ts
CHANGED
|
@@ -45,10 +45,14 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
45
45
|
protected onBeforeHide(_msg: Message): void;
|
|
46
46
|
protected onCloseRequest(msg: Message): void;
|
|
47
47
|
private _buildShell;
|
|
48
|
-
/** Raise the click-time busy overlay.
|
|
49
|
-
* can overlap; the overlay clears when the last one ends
|
|
48
|
+
/** Raise the click-time busy overlay. Each action gets a token, because an
|
|
49
|
+
* open and a branch can overlap; the overlay clears when the last one ends,
|
|
50
|
+
* and the label names the newest action still running. */
|
|
50
51
|
private _beginBusy;
|
|
51
52
|
private _endBusy;
|
|
53
|
+
/** Name the newest still-running action in the visible label and the live
|
|
54
|
+
* region, so neither keeps naming an action that ended. */
|
|
55
|
+
private _paintBusyMessage;
|
|
52
56
|
/** Show / hide the filter input. Hiding also clears the active filter, so
|
|
53
57
|
* the user never returns to an "invisible" filter narrowing the rows. */
|
|
54
58
|
private _toggleFilterBar;
|
|
@@ -90,9 +94,9 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
90
94
|
private _logError;
|
|
91
95
|
private _fetch;
|
|
92
96
|
private _toggleFavourite;
|
|
93
|
-
/** The warning a
|
|
94
|
-
*
|
|
95
|
-
*
|
|
97
|
+
/** The warning a deletion earns: with the trash setting on, a refused move
|
|
98
|
+
* may remove the conversations permanently or leave them in place, so
|
|
99
|
+
* neither the verb nor the note may promise one outcome. */
|
|
96
100
|
private get _irreversibleNote();
|
|
97
101
|
private get _disposalVerb();
|
|
98
102
|
private _removeProject;
|
|
@@ -307,8 +311,10 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
307
311
|
private _bodyEl;
|
|
308
312
|
private _loadingEl;
|
|
309
313
|
private _busyEl;
|
|
314
|
+
private _busyLabelEl;
|
|
310
315
|
private _busyTextEl;
|
|
311
|
-
private
|
|
316
|
+
private _busySeq;
|
|
317
|
+
private _busyMessages;
|
|
312
318
|
private _errorEl;
|
|
313
319
|
private _refreshBtn;
|
|
314
320
|
private _filterBtn;
|
package/lib/core/panel.js
CHANGED
|
@@ -113,8 +113,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
113
113
|
this._removingPaths = new Set();
|
|
114
114
|
this._loadingEl = null;
|
|
115
115
|
this._busyEl = null;
|
|
116
|
+
this._busyLabelEl = null;
|
|
116
117
|
this._busyTextEl = null;
|
|
117
|
-
this.
|
|
118
|
+
this._busySeq = 0;
|
|
119
|
+
this._busyMessages = new Map();
|
|
118
120
|
this._errorEl = null;
|
|
119
121
|
this._refreshBtn = null;
|
|
120
122
|
this._filterBtn = null;
|
|
@@ -186,7 +188,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
186
188
|
return this._descriptor;
|
|
187
189
|
}
|
|
188
190
|
dispose() {
|
|
191
|
+
var _a;
|
|
189
192
|
this._stopPolling();
|
|
193
|
+
// The busy overlay lives on the shell, outside this widget's tree, so
|
|
194
|
+
// removing the panel does not remove it. Left behind it would show a
|
|
195
|
+
// spinner for a panel that no longer exists.
|
|
196
|
+
(_a = this._busyEl) === null || _a === void 0 ? void 0 : _a.remove();
|
|
197
|
+
this._busyEl = null;
|
|
190
198
|
// Disabling a provider must leave its running terminals alive and only
|
|
191
199
|
// drop the tint this panel applied.
|
|
192
200
|
void this._terminals.clearColours();
|
|
@@ -394,17 +402,28 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
394
402
|
loading.appendChild(loadingSpinner);
|
|
395
403
|
// Click-time busy overlay: raised synchronously when a row is opened or
|
|
396
404
|
// branched, before the terminal probe or the branch request, so the click
|
|
397
|
-
// never reads as ignored (DEF-PANE-318).
|
|
398
|
-
//
|
|
399
|
-
//
|
|
405
|
+
// never reads as ignored (DEF-PANE-318). It sits on the LAB SHELL, not on
|
|
406
|
+
// the panel: the feedback belongs in the main working area, where the
|
|
407
|
+
// launch spinner appears, and a panel that is hidden or scrolled must not
|
|
408
|
+
// keep it out of sight (DEF-PANE-318-1). It takes no pointer events and no
|
|
409
|
+
// focus: the context menu that launched a branch stays open until its
|
|
410
|
+
// command runs, and a focus change would close it.
|
|
400
411
|
const busy = document.createElement('div');
|
|
401
|
-
busy.className = 'jp-
|
|
412
|
+
busy.className = 'jp-AiAssistants-busyOverlay';
|
|
402
413
|
busy.hidden = true;
|
|
403
414
|
const busySpinner = document.createElement('div');
|
|
404
415
|
busySpinner.className =
|
|
405
|
-
'jp-aiAssistants-spinner jp-
|
|
416
|
+
'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
|
|
406
417
|
busySpinner.setAttribute('aria-hidden', 'true');
|
|
407
418
|
busy.appendChild(busySpinner);
|
|
419
|
+
// The action, named in sight: under a reduced-motion preference the ring
|
|
420
|
+
// is held still, so the label carries the busy state with no animation.
|
|
421
|
+
// Hidden from assistive technology because the live region below
|
|
422
|
+
// announces the same words once.
|
|
423
|
+
const busyLabel = document.createElement('div');
|
|
424
|
+
busyLabel.className = 'jp-AiAssistants-busyLabel';
|
|
425
|
+
busyLabel.setAttribute('aria-hidden', 'true');
|
|
426
|
+
busy.appendChild(busyLabel);
|
|
408
427
|
// The spinner is decoration; a screen-reader user hears the action
|
|
409
428
|
// instead. The region sits on the root, always rendered and outside the
|
|
410
429
|
// overlay and the aria-busy body: a live region created inside a
|
|
@@ -428,34 +447,47 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
428
447
|
root.appendChild(error);
|
|
429
448
|
root.appendChild(body);
|
|
430
449
|
root.appendChild(loading);
|
|
431
|
-
root.appendChild(busy);
|
|
432
450
|
root.appendChild(busyText);
|
|
451
|
+
// The shell carries the visible feedback; the panel carries the
|
|
452
|
+
// announcement.
|
|
453
|
+
this._app.shell.node.appendChild(busy);
|
|
433
454
|
this._bodyEl = body;
|
|
434
455
|
this._loadingEl = loading;
|
|
435
456
|
this._busyEl = busy;
|
|
457
|
+
this._busyLabelEl = busyLabel;
|
|
436
458
|
this._busyTextEl = busyText;
|
|
437
459
|
this._errorEl = error;
|
|
438
460
|
}
|
|
439
|
-
/** Raise the click-time busy overlay.
|
|
440
|
-
* can overlap; the overlay clears when the last one ends
|
|
461
|
+
/** Raise the click-time busy overlay. Each action gets a token, because an
|
|
462
|
+
* open and a branch can overlap; the overlay clears when the last one ends,
|
|
463
|
+
* and the label names the newest action still running. */
|
|
441
464
|
_beginBusy(message) {
|
|
442
|
-
this.
|
|
465
|
+
const token = ++this._busySeq;
|
|
466
|
+
this._busyMessages.set(token, message);
|
|
443
467
|
if (this._busyEl) {
|
|
444
468
|
this._busyEl.hidden = false;
|
|
445
|
-
|
|
446
|
-
this._busyTextEl.textContent = message;
|
|
447
|
-
}
|
|
469
|
+
this._paintBusyMessage();
|
|
448
470
|
// The body is what the action changes; the root stays outside the
|
|
449
471
|
// busy subtree so the live region above is not withheld.
|
|
450
472
|
if (this._bodyEl) {
|
|
451
473
|
this._bodyEl.setAttribute('aria-busy', 'true');
|
|
452
474
|
}
|
|
453
475
|
}
|
|
476
|
+
return token;
|
|
454
477
|
}
|
|
455
|
-
_endBusy() {
|
|
456
|
-
this.
|
|
457
|
-
if (this.
|
|
478
|
+
_endBusy(token) {
|
|
479
|
+
this._busyMessages.delete(token);
|
|
480
|
+
if (this._busyMessages.size > 0) {
|
|
481
|
+
// Another action is still running; neither the visible label nor the
|
|
482
|
+
// live region may keep naming the one that just ended.
|
|
483
|
+
this._paintBusyMessage();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (this._busyEl) {
|
|
458
487
|
this._busyEl.hidden = true;
|
|
488
|
+
if (this._busyLabelEl) {
|
|
489
|
+
this._busyLabelEl.textContent = '';
|
|
490
|
+
}
|
|
459
491
|
if (this._busyTextEl) {
|
|
460
492
|
this._busyTextEl.textContent = '';
|
|
461
493
|
}
|
|
@@ -464,6 +496,20 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
464
496
|
}
|
|
465
497
|
}
|
|
466
498
|
}
|
|
499
|
+
/** Name the newest still-running action in the visible label and the live
|
|
500
|
+
* region, so neither keeps naming an action that ended. */
|
|
501
|
+
_paintBusyMessage() {
|
|
502
|
+
let message = '';
|
|
503
|
+
for (const active of this._busyMessages.values()) {
|
|
504
|
+
message = active;
|
|
505
|
+
}
|
|
506
|
+
if (this._busyLabelEl) {
|
|
507
|
+
this._busyLabelEl.textContent = message;
|
|
508
|
+
}
|
|
509
|
+
if (this._busyTextEl) {
|
|
510
|
+
this._busyTextEl.textContent = message;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
467
513
|
/** Show / hide the filter input. Hiding also clears the active filter, so
|
|
468
514
|
* the user never returns to an "invisible" filter narrowing the rows. */
|
|
469
515
|
_toggleFilterBar() {
|
|
@@ -663,14 +709,18 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
663
709
|
this._showActionError('Could not toggle favorite - try again.', err);
|
|
664
710
|
}
|
|
665
711
|
}
|
|
666
|
-
/** The warning a
|
|
667
|
-
*
|
|
668
|
-
*
|
|
712
|
+
/** The warning a deletion earns: with the trash setting on, a refused move
|
|
713
|
+
* may remove the conversations permanently or leave them in place, so
|
|
714
|
+
* neither the verb nor the note may promise one outcome. */
|
|
669
715
|
get _irreversibleNote() {
|
|
670
|
-
return this._deleteToTrash
|
|
716
|
+
return this._deleteToTrash
|
|
717
|
+
? ' A permanent removal cannot be undone.'
|
|
718
|
+
: ' This cannot be undone.';
|
|
671
719
|
}
|
|
672
720
|
get _disposalVerb() {
|
|
673
|
-
return this._deleteToTrash
|
|
721
|
+
return this._deleteToTrash
|
|
722
|
+
? 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
|
|
723
|
+
: 'deleted permanently';
|
|
674
724
|
}
|
|
675
725
|
async _removeProject(session) {
|
|
676
726
|
var _a;
|
|
@@ -805,7 +855,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
805
855
|
async _openSession(session, force, sessionId) {
|
|
806
856
|
// The click's own feedback, raised before the terminal walk and the
|
|
807
857
|
// launch - both can take seconds (DEF-PANE-318).
|
|
808
|
-
this._beginBusy(this._trans.__('Opening conversation'));
|
|
858
|
+
const busy = this._beginBusy(this._trans.__('Opening conversation'));
|
|
809
859
|
try {
|
|
810
860
|
await this._terminals.openSession(session, this._launchMode(force), sessionId);
|
|
811
861
|
// Not awaited: the open has succeeded, and the sync is not part of it.
|
|
@@ -820,7 +870,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
820
870
|
}
|
|
821
871
|
}
|
|
822
872
|
finally {
|
|
823
|
-
this._endBusy();
|
|
873
|
+
this._endBusy(busy);
|
|
824
874
|
// Reuse or fresh launch, either way the picture changed. Pull fresh
|
|
825
875
|
// state; a poll tick retries if this one fails.
|
|
826
876
|
void this._fetch().catch(() => undefined);
|
|
@@ -955,7 +1005,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
955
1005
|
if (row) {
|
|
956
1006
|
// A terminal already holding that conversation IS the answer: a second
|
|
957
1007
|
// process on one history is never what the click meant (ACC-LNCH-152).
|
|
958
|
-
|
|
1008
|
+
// The folder is required as well: a transcript re-homed into another
|
|
1009
|
+
// project keeps its id, and that terminal is not this folder's
|
|
1010
|
+
// (DEF-PANE-319).
|
|
1011
|
+
const found = await this._terminals.findForSession(row.session_id, folder);
|
|
959
1012
|
if (found) {
|
|
960
1013
|
this._terminals.focus(found.widget);
|
|
961
1014
|
return found.widget;
|
|
@@ -1049,12 +1102,12 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1049
1102
|
// Raised on the click: the branch POST mints the fork server-side (for
|
|
1050
1103
|
// Claude and Codex by running their own CLI) and can take seconds
|
|
1051
1104
|
// (DEF-PANE-318).
|
|
1052
|
-
this._beginBusy(this._trans.__('Branching conversation'));
|
|
1105
|
+
const busy = this._beginBusy(this._trans.__('Branching conversation'));
|
|
1053
1106
|
try {
|
|
1054
1107
|
await this._branchByServerCopy(session, undefined, force);
|
|
1055
1108
|
}
|
|
1056
1109
|
finally {
|
|
1057
|
-
this._endBusy();
|
|
1110
|
+
this._endBusy(busy);
|
|
1058
1111
|
}
|
|
1059
1112
|
}
|
|
1060
1113
|
/** The server mints the new id and answers with it before the terminal
|
|
@@ -1217,7 +1270,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1217
1270
|
}
|
|
1218
1271
|
/** Make another conversation the row's current one. */
|
|
1219
1272
|
async _switchBranch(session, sessionId) {
|
|
1220
|
-
var _a;
|
|
1221
1273
|
try {
|
|
1222
1274
|
const result = await requestProvider(this._descriptor.id, 'switch', this._serverSettings, {
|
|
1223
1275
|
method: 'POST',
|
|
@@ -1229,15 +1281,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1229
1281
|
if (result.current !== result.requested) {
|
|
1230
1282
|
Notification.warning('That conversation could not be made the current one.', { autoClose: 4000 });
|
|
1231
1283
|
}
|
|
1232
|
-
else {
|
|
1233
|
-
// The re-render is the only other signal, and it can read as the
|
|
1234
|
-
// opposite of success: the row now reports the conversation's true
|
|
1235
|
-
// last activity, so switching to an old one dims the row and drops
|
|
1236
|
-
// it out of Recent (DEF-PANE-181).
|
|
1237
|
-
const target = this._lastBranches.find(b => b.session_id === sessionId);
|
|
1238
|
-
const label = (_a = target === null || target === void 0 ? void 0 : target.label) !== null && _a !== void 0 ? _a : shortSessionId(sessionId, this._descriptor.sessionIdPrefix);
|
|
1239
|
-
Notification.success(`${this._lookupName(session)}: switched to ${label}`, { autoClose: 3000 });
|
|
1240
|
-
}
|
|
1241
1284
|
}
|
|
1242
1285
|
catch (err) {
|
|
1243
1286
|
Notification.error(isResponseStatus(err, 404)
|
package/lib/core/popup.js
CHANGED
|
@@ -44,9 +44,9 @@ export function showManageSessionsPopup(options) {
|
|
|
44
44
|
let items = [...options.branches];
|
|
45
45
|
const selected = new Set();
|
|
46
46
|
let deleting = false; // guards the async delete against double invocation
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
47
|
+
// Every deletion arms first and executes on the second click: the trash
|
|
48
|
+
// setting does not promise the system will accept the move, and a fallback
|
|
49
|
+
// removal is permanent.
|
|
50
50
|
let armed = false;
|
|
51
51
|
let armTimer = 0;
|
|
52
52
|
const body = document.createElement('div');
|
|
@@ -87,7 +87,7 @@ export function showManageSessionsPopup(options) {
|
|
|
87
87
|
deleteBtn.type = 'button';
|
|
88
88
|
deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
|
|
89
89
|
deleteBtn.title = options.deleteToTrash
|
|
90
|
-
? 'Deleted conversations move to the trash
|
|
90
|
+
? 'Deleted conversations move to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
|
|
91
91
|
: 'Deleted conversations are removed permanently';
|
|
92
92
|
footer.appendChild(deleteBtn);
|
|
93
93
|
// Plain visual counter (selection, or the last action), beside the button
|
|
@@ -336,12 +336,12 @@ export function showManageSessionsPopup(options) {
|
|
|
336
336
|
return;
|
|
337
337
|
}
|
|
338
338
|
// No confirmation DIALOG here: a second Lumino dialog stacked on this popup
|
|
339
|
-
// renders detached.
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
if (
|
|
339
|
+
// renders detached. The deletion confirms in the footer itself - the first
|
|
340
|
+
// click arms the button, the second executes, and any selection change or
|
|
341
|
+
// four seconds of hesitation disarms it. Every deletion arms, because the
|
|
342
|
+
// trash setting does not promise the system will accept the move, and a
|
|
343
|
+
// refused move may remove the conversation permanently or leave it.
|
|
344
|
+
if (!armed) {
|
|
345
345
|
armed = true;
|
|
346
346
|
window.clearTimeout(armTimer);
|
|
347
347
|
armTimer = window.setTimeout(() => {
|
|
@@ -384,9 +384,9 @@ export function showManageSessionsPopup(options) {
|
|
|
384
384
|
(_a = options.onSync) === null || _a === void 0 ? void 0 : _a.call(options, items);
|
|
385
385
|
render();
|
|
386
386
|
updateControls();
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
387
|
+
// One wording for both outcomes: the client cannot know whether the
|
|
388
|
+
// trash accepted the move, so it does not claim it did.
|
|
389
|
+
const moved = `${deleted} deleted`;
|
|
390
390
|
selCount.textContent = moved;
|
|
391
391
|
selCount.classList.add('jp-mod-deleted');
|
|
392
392
|
// Clear then re-set on the next tick so an identical count (two single
|
package/lib/core/terminals.d.ts
CHANGED
|
@@ -56,18 +56,26 @@ export declare class TerminalManager {
|
|
|
56
56
|
* widget is attached and visible.
|
|
57
57
|
*/
|
|
58
58
|
focus(widget: any): void;
|
|
59
|
-
/** Find an open terminal running exactly this conversation
|
|
59
|
+
/** Find an open terminal running exactly this conversation in this project,
|
|
60
|
+
* or null.
|
|
60
61
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
62
|
+
* The server reads the true id from the running process, so a session the
|
|
63
|
+
* user started by hand is recognised too, not only launches that carry an
|
|
64
|
+
* argv id. The terminal must ALSO work in the requested project. An id is
|
|
65
|
+
* not globally unique in the store: Claude re-homes a transcript into a
|
|
66
|
+
* second project under the same id, so an id match alone can name a process
|
|
67
|
+
* running it from another tree, and reusing that one opens nothing in the
|
|
68
|
+
* folder the user clicked (DEF-PANE-319). A process that changed directory
|
|
69
|
+
* WITHIN the project still matches - the probe lists every cwd in its
|
|
70
|
+
* tree. */
|
|
71
|
+
findForSession(wantedSessionId: string | undefined, projectPath: string): Promise<{
|
|
68
72
|
widget: any;
|
|
69
73
|
runningId: string | null;
|
|
70
74
|
} | null>;
|
|
75
|
+
/** Whether any process in the terminal's tree works in `projectPath` or a
|
|
76
|
+
* directory under it. No cwds means nothing to prove the terminal belongs
|
|
77
|
+
* to this project, so it is not reused. */
|
|
78
|
+
private _runsInProject;
|
|
71
79
|
/** Ask the server what a terminal is running. Null when the widget has no
|
|
72
80
|
* session name yet, or the probe fails - a terminal can vanish between
|
|
73
81
|
* enumeration and probe, which is a retry, not an error. */
|
package/lib/core/terminals.js
CHANGED
|
@@ -184,8 +184,9 @@ export class TerminalManager {
|
|
|
184
184
|
this.focus(cached.widget);
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
-
// 2. Walk every live terminal, asking the server what each is running
|
|
188
|
-
|
|
187
|
+
// 2. Walk every live terminal, asking the server what each is running in
|
|
188
|
+
// this project.
|
|
189
|
+
const found = await this.findForSession(wanted, session.project_path);
|
|
189
190
|
if (found) {
|
|
190
191
|
// Tag with the OBSERVED conversation, so a later reuse trusts a
|
|
191
192
|
// confirmed identity rather than a wish.
|
|
@@ -255,26 +256,43 @@ export class TerminalManager {
|
|
|
255
256
|
}
|
|
256
257
|
});
|
|
257
258
|
}
|
|
258
|
-
/** Find an open terminal running exactly this conversation
|
|
259
|
+
/** Find an open terminal running exactly this conversation in this project,
|
|
260
|
+
* or null.
|
|
259
261
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
|
|
262
|
+
* The server reads the true id from the running process, so a session the
|
|
263
|
+
* user started by hand is recognised too, not only launches that carry an
|
|
264
|
+
* argv id. The terminal must ALSO work in the requested project. An id is
|
|
265
|
+
* not globally unique in the store: Claude re-homes a transcript into a
|
|
266
|
+
* second project under the same id, so an id match alone can name a process
|
|
267
|
+
* running it from another tree, and reusing that one opens nothing in the
|
|
268
|
+
* folder the user clicked (DEF-PANE-319). A process that changed directory
|
|
269
|
+
* WITHIN the project still matches - the probe lists every cwd in its
|
|
270
|
+
* tree. */
|
|
271
|
+
async findForSession(wantedSessionId, projectPath) {
|
|
267
272
|
if (!this._tracker || !wantedSessionId) {
|
|
268
273
|
return null;
|
|
269
274
|
}
|
|
270
275
|
for (const widget of this._liveTerminals()) {
|
|
271
276
|
const info = await this.probe(widget);
|
|
272
|
-
if (info &&
|
|
277
|
+
if (info &&
|
|
278
|
+
info.session_id &&
|
|
279
|
+
info.session_id === wantedSessionId &&
|
|
280
|
+
this._runsInProject(info.cwds, projectPath)) {
|
|
273
281
|
return { widget, runningId: info.session_id };
|
|
274
282
|
}
|
|
275
283
|
}
|
|
276
284
|
return null;
|
|
277
285
|
}
|
|
286
|
+
/** Whether any process in the terminal's tree works in `projectPath` or a
|
|
287
|
+
* directory under it. No cwds means nothing to prove the terminal belongs
|
|
288
|
+
* to this project, so it is not reused. */
|
|
289
|
+
_runsInProject(cwds, projectPath) {
|
|
290
|
+
const root = projectPath.replace(/\/+$/, '');
|
|
291
|
+
return (cwds !== null && cwds !== void 0 ? cwds : []).some(cwd => {
|
|
292
|
+
const path = cwd.replace(/\/+$/, '');
|
|
293
|
+
return path === root || path.startsWith(`${root}/`);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
278
296
|
/** Ask the server what a terminal is running. Null when the widget has no
|
|
279
297
|
* session name yet, or the probe fails - a terminal can vanish between
|
|
280
298
|
* enumeration and probe, which is a retry, not an error. */
|
package/lib/index.js
CHANGED
|
@@ -174,9 +174,9 @@ const plugin = {
|
|
|
174
174
|
descriptor: module.descriptor,
|
|
175
175
|
hooks: module.hooks,
|
|
176
176
|
rootDir: (_a = status === null || status === void 0 ? void 0 : status.root_dir) !== null && _a !== void 0 ? _a : '',
|
|
177
|
-
// Unknown reads as "not trash"
|
|
178
|
-
//
|
|
179
|
-
//
|
|
177
|
+
// Unknown reads as "not trash", so the dialogs and the popup
|
|
178
|
+
// present the permanent-delete wording rather than promising a
|
|
179
|
+
// recoverable move the server never confirmed (DEF-135).
|
|
180
180
|
deleteToTrash: status ? status.delete_to_trash !== false : false,
|
|
181
181
|
terminalTracker,
|
|
182
182
|
fileBrowser,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.115",
|
|
4
4
|
"description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -110,7 +110,8 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
|
|
|
110
110
|
const app = {
|
|
111
111
|
serviceManager: { serverSettings: {} },
|
|
112
112
|
commands: new CommandRegistry(),
|
|
113
|
-
hasPlugin: () => false
|
|
113
|
+
hasPlugin: () => false,
|
|
114
|
+
shell: { node: document.createElement('div') }
|
|
114
115
|
} as any;
|
|
115
116
|
const settings = {
|
|
116
117
|
raw: '{}',
|
|
@@ -213,7 +214,8 @@ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () =>
|
|
|
213
214
|
const app = {
|
|
214
215
|
serviceManager: { serverSettings: {} },
|
|
215
216
|
commands: new CommandRegistry(),
|
|
216
|
-
hasPlugin: () => false
|
|
217
|
+
hasPlugin: () => false,
|
|
218
|
+
shell: { node: document.createElement('div') }
|
|
217
219
|
} as any;
|
|
218
220
|
const labShell = {
|
|
219
221
|
add: (widget: AssistantSessionsPanel) => docked.push(widget)
|
|
@@ -288,7 +290,8 @@ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () =>
|
|
|
288
290
|
{
|
|
289
291
|
serviceManager: { serverSettings: {} },
|
|
290
292
|
commands: new CommandRegistry(),
|
|
291
|
-
hasPlugin: () => false
|
|
293
|
+
hasPlugin: () => false,
|
|
294
|
+
shell: { node: document.createElement('div') }
|
|
292
295
|
} as any,
|
|
293
296
|
{ add: (widget: AssistantSessionsPanel) => docked.push(widget) } as any,
|
|
294
297
|
null,
|
|
@@ -416,7 +419,8 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
416
419
|
const app = {
|
|
417
420
|
serviceManager: { serverSettings },
|
|
418
421
|
commands: new CommandRegistry(),
|
|
419
|
-
hasPlugin: () => false
|
|
422
|
+
hasPlugin: () => false,
|
|
423
|
+
shell: { node: document.createElement('div') }
|
|
420
424
|
} as any;
|
|
421
425
|
|
|
422
426
|
const composite = options.composite;
|
|
@@ -878,6 +882,8 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
878
882
|
const shellMoves: [string, string][] = [];
|
|
879
883
|
const sequence: string[] = [];
|
|
880
884
|
const labShell = {
|
|
885
|
+
// The panel mounts its click-time overlay on the shell node.
|
|
886
|
+
node: document.createElement('div'),
|
|
881
887
|
// As the real sidebar does: a widget is hidden when it is added, and a
|
|
882
888
|
// placement the user made with JupyterLab's own command overrides the
|
|
883
889
|
// area the caller asks for.
|
|
@@ -934,7 +940,8 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
934
940
|
const app = {
|
|
935
941
|
serviceManager: { serverSettings },
|
|
936
942
|
commands,
|
|
937
|
-
hasPlugin: () => false
|
|
943
|
+
hasPlugin: () => false,
|
|
944
|
+
shell: { node: document.createElement('div') }
|
|
938
945
|
} as any;
|
|
939
946
|
|
|
940
947
|
const changed = new Signal<unknown, void>({});
|
|
@@ -1758,7 +1765,8 @@ describe('ACC-RETI-82..84 - a retired standalone extension still installed', ()
|
|
|
1758
1765
|
const app = {
|
|
1759
1766
|
serviceManager: { serverSettings },
|
|
1760
1767
|
commands: new CommandRegistry(),
|
|
1761
|
-
hasPlugin: (id: string) => installed.includes(id)
|
|
1768
|
+
hasPlugin: (id: string) => installed.includes(id),
|
|
1769
|
+
shell: { node: document.createElement('div') }
|
|
1762
1770
|
} as any;
|
|
1763
1771
|
await (plugin.activate as any)(
|
|
1764
1772
|
app,
|
|
@@ -192,7 +192,10 @@ function harness(
|
|
|
192
192
|
}
|
|
193
193
|
},
|
|
194
194
|
commands,
|
|
195
|
-
shell: {
|
|
195
|
+
shell: {
|
|
196
|
+
node: document.createElement('div'),
|
|
197
|
+
activateById: (id: string) => activated.push(id)
|
|
198
|
+
}
|
|
196
199
|
} as any;
|
|
197
200
|
const panel = new AssistantSessionsPanel({
|
|
198
201
|
app,
|
|
@@ -279,7 +282,8 @@ describe('the tile click path resumes a folder that has a conversation', () => {
|
|
|
279
282
|
'terminal/term-1': {
|
|
280
283
|
terminal_name: 'term-1',
|
|
281
284
|
running: true,
|
|
282
|
-
session_id: 'sid-proj'
|
|
285
|
+
session_id: 'sid-proj',
|
|
286
|
+
cwds: ['/srv/lab/proj']
|
|
283
287
|
}
|
|
284
288
|
});
|
|
285
289
|
const h = harness({ tracker: true });
|
|
@@ -291,6 +295,32 @@ describe('the tile click path resumes a folder that has a conversation', () => {
|
|
|
291
295
|
expect(callsTo('launch-argv')).toHaveLength(0);
|
|
292
296
|
});
|
|
293
297
|
|
|
298
|
+
it('launches here when the terminal holds the conversation in another folder', async () => {
|
|
299
|
+
// DEF-PANE-319: a transcript re-homed into another project keeps its id,
|
|
300
|
+
// so the id alone cannot prove the terminal is this folder's.
|
|
301
|
+
routes({
|
|
302
|
+
sessions: { sessions: [session()] },
|
|
303
|
+
'terminal/term-1': {
|
|
304
|
+
terminal_name: 'term-1',
|
|
305
|
+
running: true,
|
|
306
|
+
session_id: 'sid-proj',
|
|
307
|
+
cwds: ['/srv/other']
|
|
308
|
+
},
|
|
309
|
+
'launch-argv': { argv: ['/bin/testbed', '--resume', 'sid-proj'] }
|
|
310
|
+
});
|
|
311
|
+
const h = harness({ tracker: true });
|
|
312
|
+
await flush();
|
|
313
|
+
|
|
314
|
+
expect(await h.panel.launchHere('proj')).toBe(LAUNCHED_WIDGET);
|
|
315
|
+
expect(h.activated).not.toContain('terminal-open-1');
|
|
316
|
+
expect(h.launches).toEqual([
|
|
317
|
+
{
|
|
318
|
+
argv: ['/bin/testbed', '--resume', 'sid-proj'],
|
|
319
|
+
cwd: '/srv/lab/proj'
|
|
320
|
+
}
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
294
324
|
it('asks for resume argv when no terminal holds it, and launches that', async () => {
|
|
295
325
|
// ACC-LNCH-151 / ACC-LNCH-154: the row's own conversation, resumed through
|
|
296
326
|
// the sibling's terminal.
|
|
@@ -489,7 +519,8 @@ describe('the tiles the plugin adds to the Launcher', () => {
|
|
|
489
519
|
const app = {
|
|
490
520
|
serviceManager: { serverSettings: {} },
|
|
491
521
|
commands: new CommandRegistry(),
|
|
492
|
-
hasPlugin: () => false
|
|
522
|
+
hasPlugin: () => false,
|
|
523
|
+
shell: { node: document.createElement('div') }
|
|
493
524
|
} as any;
|
|
494
525
|
const labShell = {
|
|
495
526
|
add: (widget: AssistantSessionsPanel) => docked.push(widget)
|