jupyterlab_ai_code_assistants_extension 1.1.13 → 1.2.4
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 +2 -2
- package/lib/core/colour.d.ts +6 -12
- package/lib/core/colour.js +6 -35
- package/lib/core/panel.d.ts +8 -5
- package/lib/core/panel.js +15 -10
- package/lib/core/terminals.d.ts +97 -5
- package/lib/core/terminals.js +391 -62
- package/lib/core/types.d.ts +31 -0
- package/package.json +1 -1
- package/src/__tests__/colour.spec.ts +12 -33
- package/src/__tests__/tab-colour-capture.spec.ts +961 -0
- package/src/core/colour.ts +6 -35
- package/src/core/panel.ts +15 -10
- package/src/core/terminals.ts +448 -89
- package/src/core/types.ts +37 -0
package/lib/core/terminals.js
CHANGED
|
@@ -1,12 +1,50 @@
|
|
|
1
|
-
import { Dialog } from '@jupyterlab/apputils';
|
|
1
|
+
import { Dialog, Notification } from '@jupyterlab/apputils';
|
|
2
2
|
import { Widget } from '@lumino/widgets';
|
|
3
|
-
import { effectiveColour,
|
|
3
|
+
import { effectiveColour, sessionForCwds } from './colour';
|
|
4
4
|
import { LOG_PREFIX } from './registry';
|
|
5
5
|
import { requestProvider } from './request';
|
|
6
6
|
/** Colour reconciliation cadence. Matches the row poll so the two collapse
|
|
7
7
|
* into one rhythm while the panel is visible, but it is a separate timer: a
|
|
8
8
|
* terminal running the assistant is tinted whether or not the panel is open. */
|
|
9
9
|
const COLOUR_INTERVAL_MS = 30000;
|
|
10
|
+
/** Narrow the injected colour token to the ownership API, or null.
|
|
11
|
+
*
|
|
12
|
+
* A run-time probe rather than a type test, because the typings this
|
|
13
|
+
* repository compiles against are the published ones and carry `setColour`
|
|
14
|
+
* alone - see `ITabColourEvents`.
|
|
15
|
+
*/
|
|
16
|
+
function tabColourOwner(tabs) {
|
|
17
|
+
var _a;
|
|
18
|
+
const candidate = tabs;
|
|
19
|
+
if (typeof (candidate === null || candidate === void 0 ? void 0 : candidate.claim) !== 'function' ||
|
|
20
|
+
typeof ((_a = candidate.colourChanged) === null || _a === void 0 ? void 0 : _a.connect) !== 'function') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
/** Said once per page, not once per provider: every assistant panel builds a
|
|
26
|
+
* manager against the same companion, so the latch is what keeps one missing
|
|
27
|
+
* upgrade from printing the same line four times. */
|
|
28
|
+
let warnedLegacyCompanion = false;
|
|
29
|
+
/** An installed companion too old to carry the ownership API. Loud because the
|
|
30
|
+
* consequence is visible: this extension tints nothing at all against one, so
|
|
31
|
+
* a user whose assistant tabs used to be coloured watches them go plain, and
|
|
32
|
+
* the cause is one upgrade away. */
|
|
33
|
+
function warnLegacyCompanion() {
|
|
34
|
+
if (warnedLegacyCompanion) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
warnedLegacyCompanion = true;
|
|
38
|
+
const message = `jupyterlab_colourful_tab_extension is older than the release that reports tab colour choices and lets another extension own a tab. Terminal tab tinting is off until that extension is upgraded; its own right-click colours keep working as they always did.`;
|
|
39
|
+
console.warn(`${LOG_PREFIX} ${message}`);
|
|
40
|
+
// And said where the user is looking. The symptom is tabs that are simply
|
|
41
|
+
// plain, which reads as a feature that was never on rather than as something
|
|
42
|
+
// to fix, and a console line only reaches someone already suspecting the
|
|
43
|
+
// cause. The two neighbouring "an installed thing is wrong" announcements in
|
|
44
|
+
// this extension both pair the console line with this toast. It does not
|
|
45
|
+
// close on its own, because the condition does not go away on its own.
|
|
46
|
+
Notification.warning(message, { autoClose: false });
|
|
47
|
+
}
|
|
10
48
|
/** Show a modal spinner while a terminal is being launched. The caller
|
|
11
49
|
* dismisses it with `.dispose()` when the launch settles.
|
|
12
50
|
*
|
|
@@ -46,6 +84,18 @@ export function showLaunchSpinner(title) {
|
|
|
46
84
|
*/
|
|
47
85
|
export class TerminalManager {
|
|
48
86
|
constructor(options) {
|
|
87
|
+
// One claim per terminal, keyed by widget id - see `_claimTab` for why that
|
|
88
|
+
// id identifies exactly one widget.
|
|
89
|
+
this._claims = new Map();
|
|
90
|
+
// Choices made on a terminal that probed as this provider's while its
|
|
91
|
+
// conversation was not yet readable, keyed by the same widget id and holding
|
|
92
|
+
// the colour id chosen (null for a clear). A probe that FAILED records
|
|
93
|
+
// nothing - it says nothing about whose terminal it is, and a choice held on
|
|
94
|
+
// that basis lands on a conversation the user never coloured. The signal
|
|
95
|
+
// fires once, so this map is the only thing keeping such a pick alive until a
|
|
96
|
+
// pass can read the conversation and file it - and while an entry stands, its
|
|
97
|
+
// terminal is not painted, because painting is what destroys the pick.
|
|
98
|
+
this._pendingChoices = new Map();
|
|
49
99
|
// Most recent terminal per project, tagged with the conversation it runs so
|
|
50
100
|
// reuse can tell a project's branches apart.
|
|
51
101
|
this._byProject = new Map();
|
|
@@ -55,17 +105,43 @@ export class TerminalManager {
|
|
|
55
105
|
this._sessions = [];
|
|
56
106
|
this._colouredTabs = true;
|
|
57
107
|
this._colourHandle = null;
|
|
108
|
+
// Order of the colour passes, `reconcileColours` and `clearColours` alike.
|
|
109
|
+
// A pass whose generation is no longer the current one has been overtaken and
|
|
110
|
+
// neither sweeps nor paints - a counter, not a timer.
|
|
111
|
+
this._pass = 0;
|
|
58
112
|
this._disposed = false;
|
|
59
113
|
this._app = options.app;
|
|
60
114
|
this._descriptor = options.descriptor;
|
|
61
115
|
this._colours = options.colourStore;
|
|
62
116
|
this._tracker = options.terminalTracker;
|
|
63
|
-
this._colourfulTabs = options.colourfulTabs;
|
|
64
117
|
this._serverSettings = options.serverSettings;
|
|
118
|
+
// This narrowing decides whether the extension tints at all. Against a
|
|
119
|
+
// companion with no ownership API it paints nothing: a colour the user
|
|
120
|
+
// picks on a tab cannot be captured there, so the next pass would repaint
|
|
121
|
+
// the conversation's effective colour straight over the pick. Leaving the
|
|
122
|
+
// tabs alone is what makes the companion's own menu behave exactly as it
|
|
123
|
+
// did before this extension was installed.
|
|
124
|
+
this._tabs = tabColourOwner(options.colourfulTabs);
|
|
125
|
+
if (this._tabs) {
|
|
126
|
+
// Subscribed here rather than when the panel is first shown: a colour
|
|
127
|
+
// picked on a tab has to be captured whether or not the user ever opens
|
|
128
|
+
// this assistant's panel, and every panel is constructed at activation.
|
|
129
|
+
this._tabs.colourChanged.connect(this._onColourChanged, this);
|
|
130
|
+
}
|
|
131
|
+
else if (options.colourfulTabs) {
|
|
132
|
+
warnLegacyCompanion();
|
|
133
|
+
}
|
|
65
134
|
}
|
|
66
135
|
dispose() {
|
|
136
|
+
var _a;
|
|
67
137
|
this._disposed = true;
|
|
68
138
|
this.stopColourLoop();
|
|
139
|
+
(_a = this._tabs) === null || _a === void 0 ? void 0 : _a.colourChanged.disconnect(this._onColourChanged, this);
|
|
140
|
+
this._releaseClaims();
|
|
141
|
+
// A choice waiting for a conversation belongs to a panel that is still
|
|
142
|
+
// running. Kept past dispose it would be filed by nothing and would hold a
|
|
143
|
+
// widget reference for the life of the page.
|
|
144
|
+
this._pendingChoices.clear();
|
|
69
145
|
}
|
|
70
146
|
/**
|
|
71
147
|
* Open a conversation, reusing an open terminal only when that terminal is
|
|
@@ -250,74 +326,92 @@ export class TerminalManager {
|
|
|
250
326
|
*
|
|
251
327
|
* Each terminal's conversation is re-resolved on every pass rather than
|
|
252
328
|
* taken from the launch cache, which pins the launch-time conversation and
|
|
253
|
-
* goes stale on an in-place switch.
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
329
|
+
* goes stale on an in-place switch.
|
|
330
|
+
*
|
|
331
|
+
* It touches this extension's own store and nothing else - no browser
|
|
332
|
+
* storage, no other extension's records, and that must stay true. The colour
|
|
333
|
+
* the user picks on a tab arrives as a signal instead (`_onColourChanged`);
|
|
334
|
+
* reading it back out of the companion's storage is what made a recycled
|
|
335
|
+
* terminal name hand a dead terminal's colour to a live conversation,
|
|
336
|
+
* permanently, at the top of the ladder. The only write this pass makes is a
|
|
337
|
+
* choice that signal already delivered, whose conversation was unreadable at
|
|
338
|
+
* the time - see `_pendingChoices`.
|
|
257
339
|
*/
|
|
258
340
|
async reconcileColours() {
|
|
259
341
|
if (!this._colouredTabs) {
|
|
260
342
|
// No rearm: `setColouredTabs(true)` calls straight back in here.
|
|
261
343
|
return;
|
|
262
344
|
}
|
|
345
|
+
// This pass's place in the order. A pass awaiting a probe can be overtaken
|
|
346
|
+
// by a later one, and only the pass that is still current may sweep or
|
|
347
|
+
// paint: an overtaken pass would release a claim the newer one legitimately
|
|
348
|
+
// took, and repaint over what it drew.
|
|
349
|
+
const generation = ++this._pass;
|
|
263
350
|
try {
|
|
351
|
+
// Ids recognised as this assistant's on THIS pass. Every other claim is
|
|
352
|
+
// released at the end of it - see `_releaseLapsedClaims`.
|
|
353
|
+
const recognised = new Set();
|
|
354
|
+
// Ids the server positively answered are NOT this assistant's on this
|
|
355
|
+
// pass. Separate from "not in `recognised`", which also holds for a
|
|
356
|
+
// terminal whose probe simply failed.
|
|
357
|
+
const disowned = new Set();
|
|
264
358
|
await this._eachAssistantTerminal(async (widget, info) => {
|
|
265
|
-
var _a, _b, _c, _d, _e, _f, _g, _h
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (observedId && !held) {
|
|
294
|
-
// Strip OUR tint from `title.className` first. The companion
|
|
295
|
-
// extension's menu writes its own storage and the tab ELEMENT's
|
|
296
|
-
// classes, never the title - and Lumino rebuilds that element's
|
|
297
|
-
// class attribute from the title whenever the current tab changes,
|
|
298
|
-
// so a tint of ours still sitting there comes back over the colour
|
|
299
|
-
// the user just picked. With the title bare, the companion's own
|
|
300
|
-
// refresh holds their choice until this write confirms.
|
|
301
|
-
this._applyColour(widget, null);
|
|
302
|
-
held = await this._colours.set(observedId, userSet);
|
|
359
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
360
|
+
if (!this._colouredTabs ||
|
|
361
|
+
this._disposed ||
|
|
362
|
+
this._pass !== generation) {
|
|
363
|
+
// `clearColours()`, `dispose()` or a NEWER pass landed while this one
|
|
364
|
+
// was in flight. The first two release every claim and are done by
|
|
365
|
+
// the time this runs, so claiming or painting now would put back
|
|
366
|
+
// exactly what they took away, with nothing left to undo it; the
|
|
367
|
+
// third owns these tabs from here on.
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Recognition is what earns the claim, so it is taken here rather than
|
|
371
|
+
// at launch: a terminal the user started by hand is this assistant's
|
|
372
|
+
// too, and only the probe can say so.
|
|
373
|
+
recognised.add(widget.id);
|
|
374
|
+
this._claimTab(widget);
|
|
375
|
+
if (this._pendingChoices.has(widget.id)) {
|
|
376
|
+
// The conversation the running PROCESS holds, never the folder
|
|
377
|
+
// fallback below: a project holds many conversations, and filing the
|
|
378
|
+
// user's colour under the wrong one recolours a conversation they
|
|
379
|
+
// never touched.
|
|
380
|
+
if (!info.session_id) {
|
|
381
|
+
// Still unreadable, so the choice cannot be filed yet - and this
|
|
382
|
+
// tab must not be painted either. Every colour available to paint
|
|
383
|
+
// is the conversation's previous one, which is what the pick was
|
|
384
|
+
// meant to replace, and the pending record is what says the pick
|
|
385
|
+
// has not been honoured.
|
|
386
|
+
return;
|
|
303
387
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
this.
|
|
388
|
+
const choice = (_a = this._pendingChoices.get(widget.id)) !== null && _a !== void 0 ? _a : null;
|
|
389
|
+
// Dropped on the attempt, not on success. A refused write is the
|
|
390
|
+
// store being unwritable rather than the conversation being
|
|
391
|
+
// unreadable, and retrying it on every pass would warn every 30
|
|
392
|
+
// seconds for the rest of the session.
|
|
393
|
+
this._pendingChoices.delete(widget.id);
|
|
394
|
+
const filed = await this._fileChoice(info.session_id, choice);
|
|
395
|
+
if (!filed ||
|
|
396
|
+
!this._colouredTabs ||
|
|
397
|
+
this._disposed ||
|
|
398
|
+
this._pass !== generation) {
|
|
313
399
|
return;
|
|
314
400
|
}
|
|
315
401
|
}
|
|
316
|
-
const
|
|
317
|
-
|
|
402
|
+
const sessionId = (_e = (_b = info.session_id) !== null && _b !== void 0 ? _b : (_d = sessionForCwds((_c = info.cwds) !== null && _c !== void 0 ? _c : [], this._sessions)) === null || _d === void 0 ? void 0 : _d.session_id) !== null && _e !== void 0 ? _e : null;
|
|
403
|
+
const native = (_f = info.colour) !== null && _f !== void 0 ? _f : (sessionId
|
|
404
|
+
? ((_h = (_g = this._sessions.find(s => s.session_id === sessionId)) === null || _g === void 0 ? void 0 : _g.colour) !== null && _h !== void 0 ? _h : null)
|
|
318
405
|
: null);
|
|
319
406
|
this._applyColour(widget, effectiveColour(sessionId, this._descriptor.colourSource, this._colours.get(sessionId), native));
|
|
320
|
-
});
|
|
407
|
+
}, widget => disowned.add(widget.id));
|
|
408
|
+
if (this._pass === generation) {
|
|
409
|
+
// Skipped by an overtaken pass: its view of what is recognised and what
|
|
410
|
+
// is open is the older one, and acting on it would undo the newer
|
|
411
|
+
// pass's work.
|
|
412
|
+
this._releaseLapsedClaims(recognised);
|
|
413
|
+
this._dropStalePending(disowned);
|
|
414
|
+
}
|
|
321
415
|
}
|
|
322
416
|
catch (err) {
|
|
323
417
|
// Tinting reaches into another extension and a probe per terminal, so
|
|
@@ -354,8 +448,24 @@ export class TerminalManager {
|
|
|
354
448
|
* another extension, both are called as `void`, and turning tab colouring
|
|
355
449
|
* off must not spill an unhandled rejection. */
|
|
356
450
|
async clearColours() {
|
|
451
|
+
// Ownership goes with the tint. With colouring off `_captureColour` files
|
|
452
|
+
// nothing, so a claim held here would leave a colour picked on that tab
|
|
453
|
+
// persisted by nobody.
|
|
454
|
+
this._releaseClaims();
|
|
455
|
+
// And so does a choice still waiting for its conversation. Filing it later
|
|
456
|
+
// would put a colour on the top rung of a ladder this extension is not
|
|
457
|
+
// drawing, which is the same thing `_captureColour` refuses to do while
|
|
458
|
+
// colouring is off.
|
|
459
|
+
this._pendingChoices.clear();
|
|
460
|
+
const generation = ++this._pass;
|
|
357
461
|
try {
|
|
358
462
|
await this._eachAssistantTerminal(async (widget) => {
|
|
463
|
+
if (this._pass !== generation) {
|
|
464
|
+
// A `setColouredTabs(true)` overtook this clear and has already
|
|
465
|
+
// painted. Stripping now would take that tint straight off again,
|
|
466
|
+
// and nothing would put it back until the next pass.
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
359
469
|
this._applyColour(widget, null);
|
|
360
470
|
});
|
|
361
471
|
}
|
|
@@ -363,20 +473,239 @@ export class TerminalManager {
|
|
|
363
473
|
// Nothing to retry - the setting is off either way.
|
|
364
474
|
}
|
|
365
475
|
}
|
|
476
|
+
/** The user picked a colour on a tab, or cleared one.
|
|
477
|
+
*
|
|
478
|
+
* The slot itself stays synchronous - Lumino ignores what a slot returns, so
|
|
479
|
+
* an async slot's rejection would surface as an unhandled one.
|
|
480
|
+
*/
|
|
481
|
+
_onColourChanged(_sender, choice) {
|
|
482
|
+
void this._captureColour(choice);
|
|
483
|
+
}
|
|
484
|
+
/** File one choice against the conversation the coloured terminal is
|
|
485
|
+
* actually running.
|
|
486
|
+
*
|
|
487
|
+
* Only a terminal this provider owns, and only one whose conversation the
|
|
488
|
+
* server READ from the running process. A widget whose conversation cannot
|
|
489
|
+
* be read is never filed against one guessed from its folder: a project has
|
|
490
|
+
* many conversations, and filing the user's colour under the wrong one
|
|
491
|
+
* recolours a conversation they never touched. That choice waits instead,
|
|
492
|
+
* but only where the probe named the terminal - see `_pendingChoices`.
|
|
493
|
+
*/
|
|
494
|
+
async _captureColour(choice) {
|
|
495
|
+
if (!this._colouredTabs) {
|
|
496
|
+
// Nothing here is claimed while colouring is off, so the companion owns
|
|
497
|
+
// that tab and keeps the choice itself. Taking a copy would put a colour
|
|
498
|
+
// on the top rung of a ladder this extension is not drawing, to surface
|
|
499
|
+
// the day the setting is turned back on.
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const widget = this._liveTerminals().find(w => w.id === choice.widgetId);
|
|
504
|
+
if (!widget) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const info = await this.probe(widget);
|
|
508
|
+
if (!this._colouredTabs || this._disposed) {
|
|
509
|
+
// Switched off, or the panel disposed, while the probe was in flight.
|
|
510
|
+
// The check on entry cannot cover this: what is written from here is
|
|
511
|
+
// hand-set, the top rung of a ladder that is no longer being drawn, and
|
|
512
|
+
// it would surface the day the setting is turned back on.
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!info || !info.running) {
|
|
516
|
+
// Not this assistant's terminal, or a probe that could not be read at
|
|
517
|
+
// all. Neither is evidence of ownership, so neither is remembered: a
|
|
518
|
+
// failed probe says only that the server did not answer, and a choice
|
|
519
|
+
// held on that basis is filed against whatever conversation the
|
|
520
|
+
// terminal later turns out to run.
|
|
521
|
+
if (!info) {
|
|
522
|
+
// Said, not swallowed. The companion has already deleted whatever it
|
|
523
|
+
// held for this tab and persisted nothing new, so on a probe that did
|
|
524
|
+
// not answer the pick now exists only as a class Lumino rebuilds away
|
|
525
|
+
// at the next tab switch - the user watches their colour appear and
|
|
526
|
+
// then vanish. Every other losing path in this file speaks; this one
|
|
527
|
+
// is the same kind of loss.
|
|
528
|
+
console.warn(`${LOG_PREFIX}[${this._descriptor.id}] tab colour could not be attributed to a conversation - the terminal probe did not answer, so the choice was not kept.`);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
// The server ANSWERED, and the answer is that this terminal is no
|
|
532
|
+
// longer running this assistant. That ends the conversation any held
|
|
533
|
+
// choice was picked under, so the hold is released here rather than a
|
|
534
|
+
// pass later: the assistant coming back in this same terminal starts a
|
|
535
|
+
// NEW conversation, and the widget id does not change, so a choice
|
|
536
|
+
// that survives the gap is filed as hand-set against a conversation
|
|
537
|
+
// the user picked no colour for. `_dropStalePending` already acts on
|
|
538
|
+
// exactly this evidence - this is the same rule at the moment the
|
|
539
|
+
// evidence arrives.
|
|
540
|
+
this._pendingChoices.delete(choice.widgetId);
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!info.session_id) {
|
|
545
|
+
// The terminal IS this assistant's - the probe said so - and its
|
|
546
|
+
// conversation is not readable yet: the CLI has not written it. The
|
|
547
|
+
// signal fires ONCE, so dropping the choice here loses it, and the next
|
|
548
|
+
// pass then paints the conversation's effective colour straight over
|
|
549
|
+
// the pick. Remembered instead and re-attempted by that same pass,
|
|
550
|
+
// which probes every terminal anyway. Keyed by widget id, so a later
|
|
551
|
+
// choice on this tab replaces this one - the user changing their mind.
|
|
552
|
+
this._pendingChoices.set(choice.widgetId, choice.colourId);
|
|
553
|
+
// And paint the PICK, which nothing else now holds. The companion has
|
|
554
|
+
// deleted whatever it stored for this claimed tab, and the store has not
|
|
555
|
+
// got the choice yet, so without this the colour lives only as a class
|
|
556
|
+
// on the tab element that Lumino rebuilds away at the next tab switch -
|
|
557
|
+
// the user's colour appears, vanishes, and returns by itself when the
|
|
558
|
+
// pass finally files it. Painting here writes the widget's Lumino title
|
|
559
|
+
// instead, which rides re-renders. This is not the ladder being painted
|
|
560
|
+
// over the pick, which the pass below still refuses to do: it is the
|
|
561
|
+
// pick itself, and it is the only colour that is correct to show.
|
|
562
|
+
this._applyColour(widget, choice.colourId);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
// An earlier pick on this same tab, held because its conversation was
|
|
566
|
+
// unreadable then, is superseded the moment a newer one can be filed.
|
|
567
|
+
// Dropped BEFORE the write, not after it: the reconcile two lines below
|
|
568
|
+
// replays whatever is still pending, so a choice left here is filed
|
|
569
|
+
// straight back over the one about to be written - the user's older pick
|
|
570
|
+
// replacing their newer one, which is this design's own defect one layer
|
|
571
|
+
// up. Dropped on a refused write too, for the same reason: the pick that
|
|
572
|
+
// superseded it was made either way.
|
|
573
|
+
this._pendingChoices.delete(choice.widgetId);
|
|
574
|
+
if (!(await this._fileChoice(info.session_id, choice.colourId))) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
await this.reconcileColours();
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
console.warn(`${LOG_PREFIX}[${this._descriptor.id}] tab colour capture failed.`, err);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/** Write one choice into this extension's own store - a colour for a pick, a
|
|
584
|
+
* forget for a clear - and answer whether the store now holds it.
|
|
585
|
+
*
|
|
586
|
+
* Nothing is painted for a refused write, at either call site. The tab is
|
|
587
|
+
* claimed, so the companion holds no record of this choice and there is
|
|
588
|
+
* nothing in the store either - every colour this extension could paint is
|
|
589
|
+
* the conversation's PREVIOUS one, which is what the pick was meant to
|
|
590
|
+
* replace. What the user sees instead is the class the companion's menu put
|
|
591
|
+
* on the tab element, and that survives only until the next tab switch:
|
|
592
|
+
* Lumino rebuilds a tab's class attribute from the widget title whenever the
|
|
593
|
+
* current tab changes, and the title still carries that previous colour. So
|
|
594
|
+
* the choice is lost either way - the warning is what makes it something
|
|
595
|
+
* other than silent. */
|
|
596
|
+
async _fileChoice(sessionId, colourId) {
|
|
597
|
+
const stored = colourId
|
|
598
|
+
? await this._colours.set(sessionId, colourId)
|
|
599
|
+
: await this._colours.forget([sessionId]);
|
|
600
|
+
if (!stored) {
|
|
601
|
+
console.warn(`${LOG_PREFIX}[${this._descriptor.id}] tab colour choice was not stored; the conversation keeps its previous colour.`);
|
|
602
|
+
}
|
|
603
|
+
return stored;
|
|
604
|
+
}
|
|
605
|
+
/** Tell the companion this tab's colour is ours to persist.
|
|
606
|
+
*
|
|
607
|
+
* Once per terminal, not once per pass - the pass runs every 30 seconds and
|
|
608
|
+
* each claim is a disposable the companion has to hold. A widget id is a
|
|
609
|
+
* safe key for that: JupyterLab mints one uuid per widget (`id-<uuid4>`,
|
|
610
|
+
* verified against 4.6.3 on 2026-09-04) and never hands it to a second
|
|
611
|
+
* widget, so a claim held under an id belongs to the widget still wearing
|
|
612
|
+
* it. Terminal NAMES are what terminado recycles - which is why the
|
|
613
|
+
* companion fingerprints what it persists, and why nothing here keys on a
|
|
614
|
+
* name.
|
|
615
|
+
*/
|
|
616
|
+
_claimTab(widget) {
|
|
617
|
+
const tabs = this._tabs;
|
|
618
|
+
const id = widget === null || widget === void 0 ? void 0 : widget.id;
|
|
619
|
+
if (!tabs || !id || this._claims.has(id)) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
this._claims.set(id, tabs.claim(widget));
|
|
623
|
+
}
|
|
624
|
+
/** Give up every claim this pass did not renew.
|
|
625
|
+
*
|
|
626
|
+
* A claim says "this extension draws this tab and records what you pick on
|
|
627
|
+
* it", and that stops being true the moment the terminal stops probing as
|
|
628
|
+
* this assistant's - it exited, another assistant took the pty, or the
|
|
629
|
+
* widget is gone from the tracker altogether. Holding on would leave a
|
|
630
|
+
* colour picked on that tab persisted by nobody, and the map growing by one
|
|
631
|
+
* entry per closed terminal for the life of the page.
|
|
632
|
+
*
|
|
633
|
+
* A terminal whose probe merely failed is released too, since an unreadable
|
|
634
|
+
* terminal is not evidence of ownership. The next pass that reads it claims
|
|
635
|
+
* it again, and re-claiming costs nothing visible: a claim suppresses the
|
|
636
|
+
* companion's persistence and narrows its repaint to a fingerprint-verified
|
|
637
|
+
* entry, deleting nothing it stored before, and the next paint puts this
|
|
638
|
+
* conversation's colour back. */
|
|
639
|
+
_releaseLapsedClaims(recognised) {
|
|
640
|
+
this._claims.forEach((claim, id) => {
|
|
641
|
+
if (!recognised.has(id)) {
|
|
642
|
+
claim.dispose();
|
|
643
|
+
this._claims.delete(id);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
_releaseClaims() {
|
|
648
|
+
this._claims.forEach(claim => claim.dispose());
|
|
649
|
+
this._claims.clear();
|
|
650
|
+
}
|
|
651
|
+
/** Forget the choices whose premise no longer holds.
|
|
652
|
+
*
|
|
653
|
+
* A choice is held on one answer only - this assistant's terminal, its
|
|
654
|
+
* conversation not readable yet - and it stops being a choice about anything
|
|
655
|
+
* the moment either half of that stops being true.
|
|
656
|
+
*
|
|
657
|
+
* The terminal CLOSED. A pending choice is about one tab and must not outlive
|
|
658
|
+
* it. A widget id is never handed to a second widget, so the record could
|
|
659
|
+
* never be filed again - what it would do is hold that widget's id, one entry
|
|
660
|
+
* per closed terminal, for the life of the page.
|
|
661
|
+
*
|
|
662
|
+
* The terminal is no longer THIS ASSISTANT'S, per an answer the server
|
|
663
|
+
* actually gave. The conversation the pick was made under has ended, and the
|
|
664
|
+
* assistant coming back in the same terminal starts a NEW one - so a choice
|
|
665
|
+
* kept across that gap is filed against a conversation the user never picked
|
|
666
|
+
* a colour for, as hand-set, at the top of the ladder. A probe that failed is
|
|
667
|
+
* deliberately not evidence here: it says only that the server did not
|
|
668
|
+
* answer, and discarding the pick on that basis loses a colour the user chose
|
|
669
|
+
* seconds ago. */
|
|
670
|
+
_dropStalePending(disowned) {
|
|
671
|
+
if (this._pendingChoices.size === 0) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const open = new Set(this._liveTerminals().map(widget => widget.id));
|
|
675
|
+
this._pendingChoices.forEach((_choice, id) => {
|
|
676
|
+
if (!open.has(id) || disowned.has(id)) {
|
|
677
|
+
this._pendingChoices.delete(id);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
366
681
|
_applyColour(widget, colourId) {
|
|
367
|
-
if (!this.
|
|
682
|
+
if (!this._tabs || !widget || widget.isDisposed) {
|
|
368
683
|
return;
|
|
369
684
|
}
|
|
370
|
-
this.
|
|
685
|
+
this._tabs.setColour(widget, colourId);
|
|
371
686
|
}
|
|
372
|
-
async _eachAssistantTerminal(apply) {
|
|
373
|
-
|
|
687
|
+
async _eachAssistantTerminal(apply, disowned) {
|
|
688
|
+
// No ownership API, no tinting - see the constructor. The guard sits here
|
|
689
|
+
// so that every path through the tint (the pass, the clear, the capture's
|
|
690
|
+
// repaint) stops at the same place.
|
|
691
|
+
if (!this._tabs || !this._tracker) {
|
|
374
692
|
return;
|
|
375
693
|
}
|
|
376
694
|
const widgets = this._liveTerminals();
|
|
377
695
|
await Promise.all(widgets.map(async (widget) => {
|
|
378
696
|
const info = await this.probe(widget);
|
|
379
|
-
if (
|
|
697
|
+
if (widget.isDisposed || !info) {
|
|
698
|
+
// A probe that did not answer says nothing about whose terminal this
|
|
699
|
+
// is, so nothing is concluded from it - neither by the caller's
|
|
700
|
+
// `apply` nor by its `disowned`.
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (!info.running) {
|
|
704
|
+
// The server answered, and the answer is that this assistant is not
|
|
705
|
+
// what the terminal runs. That is the positive evidence `disowned`
|
|
706
|
+
// exists for, and it is why the two cases above cannot share this
|
|
707
|
+
// branch.
|
|
708
|
+
disowned === null || disowned === void 0 ? void 0 : disowned(widget);
|
|
380
709
|
return;
|
|
381
710
|
}
|
|
382
711
|
await apply(widget, info);
|
package/lib/core/types.d.ts
CHANGED
|
@@ -246,6 +246,37 @@ export interface IColourStoreResponse {
|
|
|
246
246
|
* list, since its two origins are indistinguishable there. */
|
|
247
247
|
overrides?: string[];
|
|
248
248
|
}
|
|
249
|
+
/** One colour choice made on a tab, as `jupyterlab_colourful_tab_extension`
|
|
250
|
+
* reports it: the widget whose tab was coloured, and the colour id chosen -
|
|
251
|
+
* null when the user cleared the colour. The id is the colour's NAME rather
|
|
252
|
+
* than an index into a palette, so the companion can reorder or rename its
|
|
253
|
+
* six colours without silently repainting every captured conversation. */
|
|
254
|
+
export interface IColourChoice {
|
|
255
|
+
widgetId: string;
|
|
256
|
+
colourId: string | null;
|
|
257
|
+
}
|
|
258
|
+
/** The two members `jupyterlab_colourful_tab_extension` grew so that another
|
|
259
|
+
* extension can own a tab's colour: the signal that reports a choice, and the
|
|
260
|
+
* claim that stops the companion persisting a tab another extension owns.
|
|
261
|
+
*
|
|
262
|
+
* Declared structurally here instead of being read off the imported
|
|
263
|
+
* `IColourfulTabs`, because this repository compiles against the PUBLISHED
|
|
264
|
+
* companion typings, which carry `setColour` alone. Typing the members as
|
|
265
|
+
* optional and probing for them at run time is the one shape that both builds
|
|
266
|
+
* against those typings and degrades honestly against an older companion at
|
|
267
|
+
* run time. Honestly means all the way: where the probe fails this extension
|
|
268
|
+
* leaves tabs alone rather than painting ones whose colours it could never be
|
|
269
|
+
* told about. Both members ship in the same companion release, so one probe
|
|
270
|
+
* answers for the pair. */
|
|
271
|
+
export interface ITabColourEvents {
|
|
272
|
+
colourChanged?: {
|
|
273
|
+
connect(slot: (sender: unknown, choice: IColourChoice) => void, thisArg?: unknown): boolean;
|
|
274
|
+
disconnect(slot: (sender: unknown, choice: IColourChoice) => void, thisArg?: unknown): boolean;
|
|
275
|
+
};
|
|
276
|
+
claim?: (widget: unknown) => {
|
|
277
|
+
dispose(): void;
|
|
278
|
+
};
|
|
279
|
+
}
|
|
249
280
|
/** Optional behaviour a provider module supplies on top of its descriptor.
|
|
250
281
|
* Every hook is optional - a provider that supplies none renders the plain
|
|
251
282
|
* shared panel. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.4",
|
|
4
4
|
"description": "Jupyterlab extension to replace individual jupyterlab_*_code_extension and integrate into one, that offers joint capability, joint settings page and individual right toolbar panels per code assistant, with the ability in settings to decide which code assistant support is on (default - all are enabled)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|