jupyterlab_ai_code_assistants_extension 1.2.115 → 1.2.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
37
37
  - **Branch session** - fork the current conversation into a new named session via the right-click menu; each assistant forks its own way (Claude's native `--fork-session`, Codex's app-server `thread/fork`, server-side copies for Kimi, Gemini and DeepSeek) behind the same menu item
38
38
  - **Rename session** - name a conversation from the right-click menu, and the name is written where the assistant itself reads it back: Claude gets the record its own `/rename` writes, Kimi a custom title, Gemini the chat's summary, DeepSeek its title event. The panel shows the name the assistant actually kept. Codex's name is set through Codex's own app-server, so its CLI lists the same name the panel shows
39
39
  - **Launch modes under each assistant's own name** - Claude's skip-permissions, Codex's approval bypass, Kimi's `--yolo`, Gemini's YOLO; unsafe variants carry a shield glyph in the launch menus
40
- - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Claude's own `/color` supplies its default, Kimi derives a stable colour from the conversation id; Codex, Gemini and DeepSeek have no colour of their own until you set one, and a branched session inherits its parent's colour, which it keeps even if the parent's colour later changes or is reset. Tinting needs the companion release that reports the colours you pick and lets another extension own a tab - against an older one this extension tints no tabs at all and says so once, and the companion's own right-click colours keep working as they always did
40
+ - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Claude's own `/color` supplies its default, Kimi derives a stable colour from the conversation id; Codex, Gemini and DeepSeek have no colour of their own until you set one, and a branched session inherits its parent's colour, which it keeps even if the parent's colour later changes or is reset. A conversation cleared with `/clear` keeps its colour too: the replacement inherits it, and a colour the replacement already has is kept. Tinting needs the companion release that reports the colours you pick and lets another extension own a tab - against an older one this extension tints no tabs at all and says so once, and the companion's own right-click colours keep working as they always did
41
41
  - **Your newest colour choice wins** - set a colour on a terminal tab and the extension remembers it for that conversation, so the tab shows it again when you reopen the conversation. In Claude Code, `/color` is the second way to choose, and whichever you used last wins: a colour picked on the tab after `/color` stays, and running `/color` after a pick takes over again, including `/color` with the same colour or a `/color` reset. `Reset Tab Colour (n)` in the session's right-click menu hands back every colour you set by hand on that project's conversations, and appears once there is one to hand back. Clearing the colour on the tab itself releases it too, for the conversation that terminal is running; `Reset Tab Colour` is the way back for a conversation with no tab open. A Kimi or DeepSeek conversation that has never been resumed cannot be tracked yet, so a colour set on its tab is not kept
42
42
  - **Favorites** - star projects you keep coming back to via the right-click menu; favourites from the standalone extensions are migrated on first run
43
43
  - **Remove and clean up** - drop a project's history or a project's extra parallel sessions from the right-click menu, confirmation dialog first; removed files honour JupyterLab's "move files to trash" setting
@@ -32,7 +32,7 @@ export declare function effectiveColour(sessionId: string | null, source: Colour
32
32
  * with the representative's colour. */
33
33
  export declare function sessionForCwds(cwds: string[], sessions: ISession[]): ISession | null;
34
34
  /**
35
- * Client for one provider's user-set colour store.
35
+ * Client for one provider's stored colour store, hand-set or inherited.
36
36
  *
37
37
  * The store is server-side and per provider (`providers/<id>/colours`), so
38
38
  * colours survive a reload and one assistant's colours can never be read or
@@ -49,7 +49,7 @@ export declare class ColourStore {
49
49
  /** User-set colour for a conversation, from the in-memory cache. */
50
50
  get(sessionId: string | null | undefined): string | null;
51
51
  /** Whether this conversation's colour is the user's own choice rather than
52
- * one it inherited at fork time. Only a hand-set colour is offered for
52
+ * one it inherited at fork or /clear. Only a hand-set colour is offered for
53
53
  * release: dropping an inherited one would un-inherit the branch. */
54
54
  isOverride(sessionId: string | null | undefined): boolean;
55
55
  /** Whether a write against this conversation is still unanswered - queued
@@ -94,8 +94,8 @@ export declare class ColourStore {
94
94
  * The ceiling is per request, so K ops queued behind a dead server wait K
95
95
  * times it, not it.
96
96
  *
97
- * An op must never call `load`, `set` or `forget` - it would wait on a chain
98
- * that is waiting on it. */
97
+ * An op must never call `load`, `set`, `forget` or `inherit` - it would wait
98
+ * on a chain that is waiting on it. */
99
99
  private _run;
100
100
  /** Open a write against one or more ids. Counted, not flagged: two writes to
101
101
  * one id can be queued at once, and a flag would let the first to settle
@@ -119,6 +119,22 @@ export declare class ColourStore {
119
119
  * it is checked for the value that was just written. A server that accepted
120
120
  * the request and then failed to persist it would otherwise be believed. */
121
121
  set(sessionId: string, colour: string | null): Promise<boolean>;
122
+ /** Record a colour inherited at `/clear`.
123
+ *
124
+ * The terminal process survives `/clear` and starts reporting a NEW
125
+ * conversation id, so the replacement has no colour of its own. It continues
126
+ * the conversation it replaced, not a new branch, and takes that
127
+ * conversation's effective colour over - whether the colour was picked on
128
+ * the tab or chosen by the assistant. `hand_set` false is what records the
129
+ * origin: an inherited entry, never a hand-set override, so `isOverride`
130
+ * leaves it out of the release affordance exactly as a fork's inherited tint
131
+ * is left out.
132
+ *
133
+ * The cache is reconciled from the answer the same way `set` reconciles this
134
+ * one id: the colour when the answer carries it, and the id in `_overrides`
135
+ * only when the answer names it there. Answers false when the answer does
136
+ * not carry the colour. */
137
+ inherit(sessionId: string, colour: string): Promise<boolean>;
122
138
  /** Drop stored colours - conversations that no longer exist, or overrides
123
139
  * the user released. One request for the whole list, so a release is never
124
140
  * left half applied. Same contract as `set`: the answer is checked for the
@@ -1,8 +1,9 @@
1
1
  // Terminal tab tint per conversation.
2
2
  //
3
3
  // Effective colour, highest wins:
4
- // 1. user-set - the write-back store this module keeps, per provider.
5
- // Only entries the server still holds: it releases a pick
4
+ // 1. stored - the write-back store, hand-set or inherited, that this
5
+ // module keeps per provider. Only entries the server still
6
+ // holds: it releases a pick
6
7
  // that the assistant's own later colour command overtook,
7
8
  // because the newer of the two channels wins (DEF-COLO-306)
8
9
  // 2. native - the conversation's own colour, when the assistant has one
@@ -84,7 +85,7 @@ export function sessionForCwds(cwds, sessions) {
84
85
  return best;
85
86
  }
86
87
  /**
87
- * Client for one provider's user-set colour store.
88
+ * Client for one provider's stored colour store, hand-set or inherited.
88
89
  *
89
90
  * The store is server-side and per provider (`providers/<id>/colours`), so
90
91
  * colours survive a reload and one assistant's colours can never be read or
@@ -117,7 +118,7 @@ export class ColourStore {
117
118
  return (_a = this._colours.get(sessionId)) !== null && _a !== void 0 ? _a : null;
118
119
  }
119
120
  /** Whether this conversation's colour is the user's own choice rather than
120
- * one it inherited at fork time. Only a hand-set colour is offered for
121
+ * one it inherited at fork or /clear. Only a hand-set colour is offered for
121
122
  * release: dropping an inherited one would un-inherit the branch. */
122
123
  isOverride(sessionId) {
123
124
  return !!sessionId && this._overrides.has(sessionId);
@@ -188,8 +189,8 @@ export class ColourStore {
188
189
  * The ceiling is per request, so K ops queued behind a dead server wait K
189
190
  * times it, not it.
190
191
  *
191
- * An op must never call `load`, `set` or `forget` - it would wait on a chain
192
- * that is waiting on it. */
192
+ * An op must never call `load`, `set`, `forget` or `inherit` - it would wait
193
+ * on a chain that is waiting on it. */
193
194
  _run(op) {
194
195
  const result = this._chain.then(op);
195
196
  const settled = () => undefined;
@@ -278,6 +279,59 @@ export class ColourStore {
278
279
  }
279
280
  });
280
281
  }
282
+ /** Record a colour inherited at `/clear`.
283
+ *
284
+ * The terminal process survives `/clear` and starts reporting a NEW
285
+ * conversation id, so the replacement has no colour of its own. It continues
286
+ * the conversation it replaced, not a new branch, and takes that
287
+ * conversation's effective colour over - whether the colour was picked on
288
+ * the tab or chosen by the assistant. `hand_set` false is what records the
289
+ * origin: an inherited entry, never a hand-set override, so `isOverride`
290
+ * leaves it out of the release affordance exactly as a fork's inherited tint
291
+ * is left out.
292
+ *
293
+ * The cache is reconciled from the answer the same way `set` reconciles this
294
+ * one id: the colour when the answer carries it, and the id in `_overrides`
295
+ * only when the answer names it there. Answers false when the answer does
296
+ * not carry the colour. */
297
+ async inherit(sessionId, colour) {
298
+ if (!sessionId) {
299
+ return false;
300
+ }
301
+ const ids = [sessionId];
302
+ // Marked before the op is queued, not inside it - see `isPending`.
303
+ this._openWrite(ids);
304
+ return this._run(async () => {
305
+ var _a, _b;
306
+ try {
307
+ const data = await requestProvider(this._providerId, 'colours', this._serverSettings, {
308
+ method: 'POST',
309
+ body: JSON.stringify({
310
+ session_id: sessionId,
311
+ colour,
312
+ hand_set: false
313
+ })
314
+ });
315
+ if (((_a = data.colours) !== null && _a !== void 0 ? _a : {})[sessionId] !== colour) {
316
+ return false;
317
+ }
318
+ this._colours.set(sessionId, colour);
319
+ if (((_b = data.overrides) !== null && _b !== void 0 ? _b : []).includes(sessionId)) {
320
+ this._overrides.add(sessionId);
321
+ }
322
+ else {
323
+ this._overrides.delete(sessionId);
324
+ }
325
+ return true;
326
+ }
327
+ catch (_err) {
328
+ return false;
329
+ }
330
+ finally {
331
+ this._closeWrite(ids);
332
+ }
333
+ });
334
+ }
281
335
  /** Drop stored colours - conversations that no longer exist, or overrides
282
336
  * the user released. One request for the whole list, so a release is never
283
337
  * left half applied. Same contract as `set`: the answer is checked for the
@@ -212,6 +212,7 @@ export declare class TerminalManager {
212
212
  private readonly _serverSettings;
213
213
  private readonly _claims;
214
214
  private readonly _pendingChoices;
215
+ private readonly _lastPainted;
215
216
  private readonly _byProject;
216
217
  private readonly _pending;
217
218
  private _sessions;
@@ -98,6 +98,14 @@ export class TerminalManager {
98
98
  // pass can read the conversation and file it - and while an entry stands, its
99
99
  // terminal is not painted, because painting is what destroys the pick.
100
100
  this._pendingChoices = new Map();
101
+ // The conversation each terminal was last PAINTED from, and the effective
102
+ // colour it was painted. The probe follows the pidfile, so `/clear` answers
103
+ // the same widget with a new conversation id; a difference against this
104
+ // record is what tells an in-place replacement from a terminal that simply
105
+ // started later, and is the colour the replacement inherits. Pruned by
106
+ // `clearColours` and `dispose` so a tab that is no longer drawn leaves no
107
+ // entry behind.
108
+ this._lastPainted = new Map();
101
109
  // Most recent terminal per project, tagged with the conversation it runs so
102
110
  // reuse can tell a project's branches apart.
103
111
  this._byProject = new Map();
@@ -146,6 +154,9 @@ export class TerminalManager {
146
154
  // running. Kept past dispose it would be filed by nothing and would hold a
147
155
  // widget reference for the life of the page.
148
156
  this._pendingChoices.clear();
157
+ // Nothing is painted from here on, so the record would only hold widget
158
+ // ids for the life of the page.
159
+ this._lastPainted.clear();
149
160
  }
150
161
  /**
151
162
  * Open a conversation, reusing an open terminal only when that terminal is
@@ -378,7 +389,7 @@ export class TerminalManager {
378
389
  // terminal whose probe simply failed.
379
390
  const disowned = new Set();
380
391
  await this._eachAssistantTerminal(async (widget, info) => {
381
- var _a, _b, _c, _d, _e, _f, _g, _h;
392
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
382
393
  if (!this._colouredTabs ||
383
394
  this._disposed ||
384
395
  this._pass !== generation) {
@@ -446,7 +457,34 @@ export class TerminalManager {
446
457
  const native = (_f = info.colour) !== null && _f !== void 0 ? _f : (sessionId
447
458
  ? ((_h = (_g = this._sessions.find(s => s.session_id === sessionId)) === null || _g === void 0 ? void 0 : _g.colour) !== null && _h !== void 0 ? _h : null)
448
459
  : null);
449
- this._applyColour(widget, effectiveColour(sessionId, this._descriptor.colourSource, this._colours.get(sessionId), native));
460
+ // The probe follows the pidfile, so an in-place `/clear` answers the
461
+ // SAME terminal with a NEW conversation id. The replacement continues
462
+ // the conversation it replaced and takes over the effective colour
463
+ // that one was wearing - a pick made on the tab or a colour the
464
+ // assistant chose. A stored colour or an assistant-chosen colour on
465
+ // the new conversation is its own answer, though: only a bare new id
466
+ // is filled in, so the old colour never overwrites it.
467
+ let colour = effectiveColour(sessionId, this._descriptor.colourSource, this._colours.get(sessionId), native);
468
+ if (sessionId) {
469
+ const last = this._lastPainted.get(widget.id);
470
+ if (last &&
471
+ last.sessionId !== sessionId &&
472
+ last.colour !== null &&
473
+ this._colours.get(sessionId) === null &&
474
+ native === null) {
475
+ await this._colours.inherit(sessionId, last.colour);
476
+ if (!this._colouredTabs ||
477
+ this._disposed ||
478
+ this._pass !== generation) {
479
+ // Colouring went off, the panel went away or a newer pass took
480
+ // over while the write was queued. That pass paints the tabs.
481
+ return;
482
+ }
483
+ colour = (_j = this._colours.get(sessionId)) !== null && _j !== void 0 ? _j : last.colour;
484
+ }
485
+ this._lastPainted.set(widget.id, { sessionId, colour });
486
+ }
487
+ this._applyColour(widget, colour);
450
488
  }, widget => disowned.add(widget.id));
451
489
  if (this._pass === generation) {
452
490
  // Skipped by an overtaken pass: its view of what is recognised and what
@@ -487,6 +525,10 @@ export class TerminalManager {
487
525
  // drawing, which is the same thing `_captureColour` refuses to do while
488
526
  // colouring is off.
489
527
  this._pendingChoices.clear();
528
+ // Every terminal this manager still sees is about to be painted null, so
529
+ // no recorded paint describes what is on screen any more - and a `/clear`
530
+ // that happened while colouring was off must not be inherited from.
531
+ this._lastPainted.clear();
490
532
  const generation = ++this._pass;
491
533
  try {
492
534
  await this._eachAssistantTerminal(async (widget) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.115",
3
+ "version": "1.2.120",
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",
@@ -190,6 +190,32 @@ describe('ColourStore.set', () => {
190
190
  });
191
191
  });
192
192
 
193
+ describe('ColourStore.inherit', () => {
194
+ // The `/clear` path: the replacement conversation takes the effective colour
195
+ // of the one it replaced, but as an INHERITED entry. Only a hand-set colour
196
+ // is offered for release, and releasing this one would strip the branch.
197
+ const store = () => new ColourStore('claude', {} as any);
198
+
199
+ beforeEach(() => request.mockReset());
200
+
201
+ it('records the colour as inherited, not as the user own choice', async () => {
202
+ request.mockResolvedValue({
203
+ colours: { [SESSION_B]: 'sky' },
204
+ overrides: []
205
+ } as any);
206
+ const colours = store();
207
+ expect(await colours.inherit(SESSION_B, 'sky')).toBe(true);
208
+
209
+ expect(JSON.parse((request.mock.calls[0][3] as any).body)).toEqual({
210
+ session_id: SESSION_B,
211
+ colour: 'sky',
212
+ hand_set: false
213
+ });
214
+ expect(colours.get(SESSION_B)).toEqual('sky');
215
+ expect(colours.isOverride(SESSION_B)).toBe(false);
216
+ });
217
+ });
218
+
193
219
  describe('ColourStore origins', () => {
194
220
  // A stored colour is either the user's own choice or one a fork inherited.
195
221
  // Both beat the default; only the first is offered for release, or one click
@@ -525,7 +551,7 @@ describe('sessionForCwds', () => {
525
551
  * unrunnable invariant (see `core-neutrality.spec.ts`): by reading the source.
526
552
  */
527
553
  describe('ColourStore queue re-entrancy', () => {
528
- const QUEUED = ['load', 'set', 'forget'];
554
+ const QUEUED = ['load', 'set', 'forget', 'inherit'];
529
555
 
530
556
  /** The body of one method, by brace matching from its declaration. */
531
557
  const bodyOf = (source: string, name: string): string => {
@@ -992,6 +992,102 @@ describe('the paint pass', () => {
992
992
  });
993
993
  });
994
994
 
995
+ describe('a conversation replaced in place by /clear', () => {
996
+ /** The POSTs that record an inherited colour rather than a pick on a tab. */
997
+ const inheritPosts = (): Record<string, unknown>[] =>
998
+ request.mock.calls
999
+ .filter(
1000
+ call =>
1001
+ call[1] === 'colours' &&
1002
+ (call[3] as RequestInit)?.method === 'POST' &&
1003
+ JSON.parse(String((call[3] as RequestInit).body)).hand_set === false
1004
+ )
1005
+ .map(call => JSON.parse(String((call[3] as RequestInit).body)));
1006
+
1007
+ it('hands the replaced conversation effective colour to the new id', async () => {
1008
+ // A pick on the tab of the conversation that `/clear` replaced. The probe
1009
+ // follows the pidfile, so the next pass answers the same terminal with the
1010
+ // new conversation id and the old colour must carry over.
1011
+ server.colours[SESSION] = 'sky';
1012
+ server.overrides = [SESSION];
1013
+ await store.load();
1014
+
1015
+ await manager.reconcileColours();
1016
+ expect(paints(terminals[0]).pop()).toEqual('sky');
1017
+
1018
+ server.probes['1'] = {
1019
+ terminal_name: '1',
1020
+ running: true,
1021
+ session_id: OTHER_SESSION
1022
+ };
1023
+ await manager.reconcileColours();
1024
+
1025
+ expect(inheritPosts()).toEqual([
1026
+ { session_id: OTHER_SESSION, colour: 'sky', hand_set: false }
1027
+ ]);
1028
+ expect(store.get(OTHER_SESSION)).toEqual('sky');
1029
+ // Inherited, not hand-set: the release affordance must not offer it.
1030
+ expect(store.isOverride(OTHER_SESSION)).toBe(false);
1031
+ expect(paints(terminals[0]).pop()).toEqual('sky');
1032
+ });
1033
+
1034
+ it('leaves a colour the new conversation already answers alone', async () => {
1035
+ // The new conversation already has a stored colour of its own. It is not
1036
+ // bare, so the replaced conversation's colour must not be written over it.
1037
+ server.colours[SESSION] = 'sky';
1038
+ server.overrides = [SESSION];
1039
+ await store.load();
1040
+ await manager.reconcileColours();
1041
+
1042
+ server.colours[OTHER_SESSION] = 'mint';
1043
+ server.overrides = [SESSION, OTHER_SESSION];
1044
+ await store.load();
1045
+ server.probes['1'] = {
1046
+ terminal_name: '1',
1047
+ running: true,
1048
+ session_id: OTHER_SESSION
1049
+ };
1050
+ await manager.reconcileColours();
1051
+
1052
+ expect(inheritPosts()).toEqual([]);
1053
+ expect(paints(terminals[0]).pop()).toEqual('mint');
1054
+ });
1055
+
1056
+ it('does not inherit over a colour the new conversation already answers with', async () => {
1057
+ // The replacement answers the probe with an assistant-chosen colour. That
1058
+ // is the new conversation's own answer even though the store holds nothing
1059
+ // for it, so `native === null` is what keeps the replaced conversation's
1060
+ // colour from overwriting it.
1061
+ //
1062
+ // The descriptor is switched to `native` so the probe's colour reaches the
1063
+ // ladder: under the default `derived` source a native colour is only a
1064
+ // guard input and would never be painted.
1065
+ manager.dispose();
1066
+ manager = makeManager({
1067
+ descriptor: { ...DESCRIPTOR, colourSource: 'native' }
1068
+ });
1069
+
1070
+ server.colours[SESSION] = 'sky';
1071
+ server.overrides = [SESSION];
1072
+ await store.load();
1073
+ await manager.reconcileColours();
1074
+ expect(paints(terminals[0]).pop()).toEqual('sky');
1075
+
1076
+ server.probes['1'] = {
1077
+ terminal_name: '1',
1078
+ running: true,
1079
+ session_id: OTHER_SESSION,
1080
+ colour: 'mint'
1081
+ };
1082
+ await manager.reconcileColours();
1083
+
1084
+ // Nothing was written for the replacement: its own colour outranks the
1085
+ // cleared conversation's, and the tab paints that colour.
1086
+ expect(inheritPosts()).toEqual([]);
1087
+ expect(paints(terminals[0]).pop()).toEqual('mint');
1088
+ });
1089
+ });
1090
+
995
1091
  /** Manager options against one companion, for the isolated-module case that
996
1092
  * cannot use `makeManager`. */
997
1093
  function optionsFor(colourfulTabs: unknown): TerminalManager.IOptions {
@@ -1,8 +1,9 @@
1
1
  // Terminal tab tint per conversation.
2
2
  //
3
3
  // Effective colour, highest wins:
4
- // 1. user-set - the write-back store this module keeps, per provider.
5
- // Only entries the server still holds: it releases a pick
4
+ // 1. stored - the write-back store, hand-set or inherited, that this
5
+ // module keeps per provider. Only entries the server still
6
+ // holds: it releases a pick
6
7
  // that the assistant's own later colour command overtook,
7
8
  // because the newer of the two channels wins (DEF-COLO-306)
8
9
  // 2. native - the conversation's own colour, when the assistant has one
@@ -101,7 +102,7 @@ export function sessionForCwds(
101
102
  }
102
103
 
103
104
  /**
104
- * Client for one provider's user-set colour store.
105
+ * Client for one provider's stored colour store, hand-set or inherited.
105
106
  *
106
107
  * The store is server-side and per provider (`providers/<id>/colours`), so
107
108
  * colours survive a reload and one assistant's colours can never be read or
@@ -128,7 +129,7 @@ export class ColourStore {
128
129
  }
129
130
 
130
131
  /** Whether this conversation's colour is the user's own choice rather than
131
- * one it inherited at fork time. Only a hand-set colour is offered for
132
+ * one it inherited at fork or /clear. Only a hand-set colour is offered for
132
133
  * release: dropping an inherited one would un-inherit the branch. */
133
134
  isOverride(sessionId: string | null | undefined): boolean {
134
135
  return !!sessionId && this._overrides.has(sessionId);
@@ -206,8 +207,8 @@ export class ColourStore {
206
207
  * The ceiling is per request, so K ops queued behind a dead server wait K
207
208
  * times it, not it.
208
209
  *
209
- * An op must never call `load`, `set` or `forget` - it would wait on a chain
210
- * that is waiting on it. */
210
+ * An op must never call `load`, `set`, `forget` or `inherit` - it would wait
211
+ * on a chain that is waiting on it. */
211
212
  private _run<T>(op: () => Promise<T>): Promise<T> {
212
213
  const result = this._chain.then(op);
213
214
  const settled = () => undefined;
@@ -298,6 +299,61 @@ export class ColourStore {
298
299
  });
299
300
  }
300
301
 
302
+ /** Record a colour inherited at `/clear`.
303
+ *
304
+ * The terminal process survives `/clear` and starts reporting a NEW
305
+ * conversation id, so the replacement has no colour of its own. It continues
306
+ * the conversation it replaced, not a new branch, and takes that
307
+ * conversation's effective colour over - whether the colour was picked on
308
+ * the tab or chosen by the assistant. `hand_set` false is what records the
309
+ * origin: an inherited entry, never a hand-set override, so `isOverride`
310
+ * leaves it out of the release affordance exactly as a fork's inherited tint
311
+ * is left out.
312
+ *
313
+ * The cache is reconciled from the answer the same way `set` reconciles this
314
+ * one id: the colour when the answer carries it, and the id in `_overrides`
315
+ * only when the answer names it there. Answers false when the answer does
316
+ * not carry the colour. */
317
+ async inherit(sessionId: string, colour: string): Promise<boolean> {
318
+ if (!sessionId) {
319
+ return false;
320
+ }
321
+ const ids = [sessionId];
322
+ // Marked before the op is queued, not inside it - see `isPending`.
323
+ this._openWrite(ids);
324
+ return this._run(async () => {
325
+ try {
326
+ const data = await requestProvider<IColourStoreResponse>(
327
+ this._providerId,
328
+ 'colours',
329
+ this._serverSettings,
330
+ {
331
+ method: 'POST',
332
+ body: JSON.stringify({
333
+ session_id: sessionId,
334
+ colour,
335
+ hand_set: false
336
+ })
337
+ }
338
+ );
339
+ if ((data.colours ?? {})[sessionId] !== colour) {
340
+ return false;
341
+ }
342
+ this._colours.set(sessionId, colour);
343
+ if ((data.overrides ?? []).includes(sessionId)) {
344
+ this._overrides.add(sessionId);
345
+ } else {
346
+ this._overrides.delete(sessionId);
347
+ }
348
+ return true;
349
+ } catch (_err) {
350
+ return false;
351
+ } finally {
352
+ this._closeWrite(ids);
353
+ }
354
+ });
355
+ }
356
+
301
357
  /** Drop stored colours - conversations that no longer exist, or overrides
302
358
  * the user released. One request for the whole list, so a release is never
303
359
  * left half applied. Same contract as `set`: the answer is checked for the
@@ -151,6 +151,9 @@ export class TerminalManager {
151
151
  // running. Kept past dispose it would be filed by nothing and would hold a
152
152
  // widget reference for the life of the page.
153
153
  this._pendingChoices.clear();
154
+ // Nothing is painted from here on, so the record would only hold widget
155
+ // ids for the life of the page.
156
+ this._lastPainted.clear();
154
157
  }
155
158
 
156
159
  /**
@@ -508,15 +511,44 @@ export class TerminalManager {
508
511
  ? (this._sessions.find(s => s.session_id === sessionId)?.colour ??
509
512
  null)
510
513
  : null);
511
- this._applyColour(
512
- widget,
513
- effectiveColour(
514
- sessionId,
515
- this._descriptor.colourSource,
516
- this._colours.get(sessionId),
517
- native
518
- )
514
+
515
+ // The probe follows the pidfile, so an in-place `/clear` answers the
516
+ // SAME terminal with a NEW conversation id. The replacement continues
517
+ // the conversation it replaced and takes over the effective colour
518
+ // that one was wearing - a pick made on the tab or a colour the
519
+ // assistant chose. A stored colour or an assistant-chosen colour on
520
+ // the new conversation is its own answer, though: only a bare new id
521
+ // is filled in, so the old colour never overwrites it.
522
+ let colour = effectiveColour(
523
+ sessionId,
524
+ this._descriptor.colourSource,
525
+ this._colours.get(sessionId),
526
+ native
519
527
  );
528
+ if (sessionId) {
529
+ const last = this._lastPainted.get(widget.id);
530
+ if (
531
+ last &&
532
+ last.sessionId !== sessionId &&
533
+ last.colour !== null &&
534
+ this._colours.get(sessionId) === null &&
535
+ native === null
536
+ ) {
537
+ await this._colours.inherit(sessionId, last.colour);
538
+ if (
539
+ !this._colouredTabs ||
540
+ this._disposed ||
541
+ this._pass !== generation
542
+ ) {
543
+ // Colouring went off, the panel went away or a newer pass took
544
+ // over while the write was queued. That pass paints the tabs.
545
+ return;
546
+ }
547
+ colour = this._colours.get(sessionId) ?? last.colour;
548
+ }
549
+ this._lastPainted.set(widget.id, { sessionId, colour });
550
+ }
551
+ this._applyColour(widget, colour);
520
552
  },
521
553
  widget => disowned.add(widget.id)
522
554
  );
@@ -561,6 +593,10 @@ export class TerminalManager {
561
593
  // drawing, which is the same thing `_captureColour` refuses to do while
562
594
  // colouring is off.
563
595
  this._pendingChoices.clear();
596
+ // Every terminal this manager still sees is about to be painted null, so
597
+ // no recorded paint describes what is on screen any more - and a `/clear`
598
+ // that happened while colouring was off must not be inherited from.
599
+ this._lastPainted.clear();
564
600
  const generation = ++this._pass;
565
601
  try {
566
602
  await this._eachAssistantTerminal(async widget => {
@@ -884,6 +920,17 @@ export class TerminalManager {
884
920
  // pass can read the conversation and file it - and while an entry stands, its
885
921
  // terminal is not painted, because painting is what destroys the pick.
886
922
  private readonly _pendingChoices: Map<string, string | null> = new Map();
923
+ // The conversation each terminal was last PAINTED from, and the effective
924
+ // colour it was painted. The probe follows the pidfile, so `/clear` answers
925
+ // the same widget with a new conversation id; a difference against this
926
+ // record is what tells an in-place replacement from a terminal that simply
927
+ // started later, and is the colour the replacement inherits. Pruned by
928
+ // `clearColours` and `dispose` so a tab that is no longer drawn leaves no
929
+ // entry behind.
930
+ private readonly _lastPainted: Map<
931
+ string,
932
+ { sessionId: string; colour: string | null }
933
+ > = new Map();
887
934
  // Most recent terminal per project, tagged with the conversation it runs so
888
935
  // reuse can tell a project's branches apart.
889
936
  private readonly _byProject: Map<
package/style/base.css CHANGED
@@ -550,26 +550,6 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
550
550
  order: -1;
551
551
  }
552
552
 
553
- /* The spin carries "busy", not information, so under a reduced-motion
554
- preference it degrades to the same ring held still and dimmed. */
555
- @media (prefers-reduced-motion: reduce) {
556
- .jp-AiAssistantsPanel-iconButton.jp-mod-spinning svg,
557
- .jp-aiAssistants-spinner,
558
- .jp-AiAssistantsPanel-spinner {
559
- animation: none;
560
- opacity: 0.5;
561
- }
562
-
563
- /* The click-time busy spinner is the only thing on screen while a click is
564
- being handled, so it keeps full opacity: dimmed, its brand arc measures
565
- 1.74:1 on the light panel, below the 3:1 minimum for a non-text cue. Held
566
- still it would read as idle, so the overlay names the action in a visible
567
- label beside it; the label, not an animation, carries the busy state. */
568
- .jp-AiAssistants-busySpinner {
569
- opacity: 1;
570
- }
571
- }
572
-
573
553
  /* Full-panel veil shown only during an explicit refresh (refresh button or
574
554
  panel show) - the background poll never raises it. The rule is gated on
575
555
  :not([hidden]) because this author `display` would otherwise beat the UA