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.
@@ -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, readUserSetTabColour, sessionForCwds } from './colour';
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. On the way through, a colour the user
254
- * set by hand on the tab is written back into this provider's colour store,
255
- * which is what makes "set the tab colour" mean "this conversation's colour"
256
- * even for an assistant whose CLI has no colour concept.
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, _j, _k, _l;
266
- const sessionId = (_d = (_a = info.session_id) !== null && _a !== void 0 ? _a : (_c = sessionForCwds((_b = info.cwds) !== null && _b !== void 0 ? _b : [], this._sessions)) === null || _c === void 0 ? void 0 : _c.session_id) !== null && _d !== void 0 ? _d : null;
267
- const terminalName = (_g = (_f = (_e = widget === null || widget === void 0 ? void 0 : widget.content) === null || _e === void 0 ? void 0 : _e.session) === null || _f === void 0 ? void 0 : _f.name) !== null && _g !== void 0 ? _g : '';
268
- // Write-back. The tab is the control surface for EVERY assistant,
269
- // native-colour ones included: setting a colour there diverges from
270
- // whatever the CLI's own colour said, and that later choice is the one
271
- // remembered from here on.
272
- //
273
- // Keyed on the OBSERVED conversation only. `sessionId` above may be the
274
- // project's representative conversation resolved from a cwd, which is
275
- // good enough to paint with but not to write against: filing the user's
276
- // colour under a guessed id recolours a conversation they never touched.
277
- const userSet = terminalName
278
- ? readUserSetTabColour(terminalName)
279
- : null;
280
- if (userSet) {
281
- const observedId = (_h = info.session_id) !== null && _h !== void 0 ? _h : null;
282
- // Held by the store already (an earlier pass captured it), or
283
- // captured right now with the write confirmed by the server.
284
- // Held AS THE USER'S OWN. Matching the colour alone is not enough: a
285
- // branch born wearing this tint holds it as inherited, so re-picking
286
- // the same colour on that tab would be swallowed and the choice
287
- // would never become releasable. The cache carries only values the
288
- // server confirmed, so reading it is the confirmation - which is why
289
- // it is never written ahead of the answer.
290
- let held = !!observedId &&
291
- this._colours.get(observedId) === userSet &&
292
- this._colours.isOverride(observedId);
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
- if (!held) {
305
- // Nothing remembers this colour yet, so painting one would destroy
306
- // it: any colour applied makes the colourful-tab extension release
307
- // its menu entry. Strip OUR tint instead and stop - Lumino rebuilds
308
- // the tab's classes from `title.className`, so a tint left there by
309
- // an earlier pass would otherwise keep showing in place of the
310
- // colour the user just picked, and the companion repaints its own
311
- // once the class is gone. The next pass retries the capture.
312
- this._applyColour(widget, null);
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 native = (_j = info.colour) !== null && _j !== void 0 ? _j : (sessionId
317
- ? ((_l = (_k = this._sessions.find(s => s.session_id === sessionId)) === null || _k === void 0 ? void 0 : _k.colour) !== null && _l !== void 0 ? _l : null)
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._colourfulTabs || !widget || widget.isDisposed) {
682
+ if (!this._tabs || !widget || widget.isDisposed) {
368
683
  return;
369
684
  }
370
- this._colourfulTabs.setColour(widget, colourId);
685
+ this._tabs.setColour(widget, colourId);
371
686
  }
372
- async _eachAssistantTerminal(apply) {
373
- if (!this._colourfulTabs || !this._tracker) {
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 (!info || !info.running || widget.isDisposed) {
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);
@@ -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.1.13",
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",