jupyterlab_ai_code_assistants_extension 0.6.17 → 1.0.10

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.
Files changed (42) hide show
  1. package/README.md +5 -2
  2. package/lib/core/colour.d.ts +101 -8
  3. package/lib/core/colour.js +218 -50
  4. package/lib/core/icons.js +8 -2
  5. package/lib/core/modes.d.ts +19 -0
  6. package/lib/core/modes.js +36 -0
  7. package/lib/core/panel.d.ts +114 -6
  8. package/lib/core/panel.js +699 -127
  9. package/lib/core/popup.d.ts +4 -0
  10. package/lib/core/popup.js +5 -2
  11. package/lib/core/registry.d.ts +4 -1
  12. package/lib/core/registry.js +4 -1
  13. package/lib/core/request.d.ts +47 -0
  14. package/lib/core/request.js +97 -6
  15. package/lib/core/terminals.d.ts +14 -4
  16. package/lib/core/terminals.js +86 -20
  17. package/lib/core/types.d.ts +35 -27
  18. package/lib/index.js +44 -10
  19. package/lib/providers/claude.js +3 -18
  20. package/lib/providers/codex.js +1 -1
  21. package/lib/providers/gemini.js +3 -1
  22. package/package.json +1 -2
  23. package/src/__tests__/colour.spec.ts +451 -1
  24. package/src/__tests__/core-neutrality.spec.ts +126 -9
  25. package/src/__tests__/launch-mode.spec.ts +50 -3
  26. package/src/__tests__/panel.spec.ts +623 -0
  27. package/src/__tests__/registry.spec.ts +0 -48
  28. package/src/__tests__/request.spec.ts +197 -0
  29. package/src/core/colour.ts +234 -60
  30. package/src/core/icons.ts +8 -2
  31. package/src/core/modes.ts +48 -0
  32. package/src/core/panel.ts +779 -135
  33. package/src/core/popup.ts +9 -2
  34. package/src/core/registry.ts +4 -1
  35. package/src/core/request.ts +107 -11
  36. package/src/core/terminals.ts +85 -19
  37. package/src/core/types.ts +35 -30
  38. package/src/index.ts +53 -8
  39. package/src/providers/claude.ts +4 -26
  40. package/src/providers/codex.ts +2 -6
  41. package/src/providers/gemini.ts +3 -1
  42. package/style/base.css +60 -23
package/README.md CHANGED
@@ -33,13 +33,14 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
33
33
  - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short id with last-activity time; "Manage Sessions..." opens a searchable popup over the full list with multi-select delete and per-row open and copy-id buttons
34
34
  - **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 `codex fork`, server-side copies for Kimi and Gemini) behind the same menu item
35
35
  - **Launch modes under each assistant's own name** - Claude's skip-permissions, Codex's approval bypass, Kimi's `--yolo`, Gemini's YOLO and approval modes; unsafe variants carry a warning glyph
36
- - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Assistants without a colour concept get a stable colour from the extension's own store, and a branched session inherits its parent's colour
36
+ - **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 and Gemini 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
37
+ - **Your own tab colour wins** - set a colour on a terminal tab and the extension remembers it for that conversation, overriding whatever the assistant chose. `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. Use it rather than the tab menu's own Clear, which will not release a stored colour. A Codex or Kimi conversation that has never been resumed cannot be tracked yet, so a colour set on its tab is not kept
37
38
  - **Favorites** - star projects you keep coming back to via the right-click menu; favourites from the standalone extensions are migrated on first run
38
39
  - **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
39
40
  - **Activity at a glance** - each row shows its last activity in an aligned column; rows active within the last minute light up, rows idle for over a week dim, and rows with parallel conversations show a branch icon with the count
40
41
  - **Remote control indicator and background agents** (Claude) - a green dot marks sessions actively under remote control, and a conversation held by a running background agent shows a `bg` chip; clicking attaches to the agent instead of copying it
41
42
  - **Search** - fuzzy filter per panel, toggled by the funnel button
42
- - **Presentation modes** - label rows by session name, folder name, or path relative to the JupyterLab root
43
+ - **Presentation modes** - label rows by session name or by path relative to the JupyterLab root
43
44
  - **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant instead of showing a duplicate
44
45
  - **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` does not show a panel
45
46
 
@@ -74,6 +75,8 @@ This package supersedes `jupyterlab_claude_code_extension`, `jupyterlab_codex_ex
74
75
  pip uninstall jupyterlab_claude_code_extension jupyterlab_codex_extension jupyterlab_kimi_code_extension
75
76
  ```
76
77
 
78
+ Tab colours stored by versions of THIS extension before 1.0.0 are carried over and keep tinting their tabs, but `Reset Tab Colour` will not offer them: the store from that version cannot tell a colour you set from one a branch inherited, and offering the wrong one would strip a branch of the colour it was forked with. Set the colour on that tab once more and it becomes yours to reset.
79
+
77
80
  While a standalone extension is still installed, its panel is shown and this extension's panel for that assistant stays out of the way.
78
81
 
79
82
  ## Uninstall
@@ -8,14 +8,24 @@ export declare const TAB_COLOUR_IDS: readonly string[];
8
8
  /** Map a conversation id onto one of the six colour ids. FNV-1a (32-bit) - a
9
9
  * stable string hash with good avalanche on short hex-ish ids; `Math.imul`
10
10
  * keeps the multiplication in 32-bit integer semantics. The same id always
11
- * yields the same colour, on every reload and in every browser. */
11
+ * yields the same colour, on every reload and in every browser.
12
+ *
13
+ * Hashed over the id's UTF-8 BYTES, which is what FNV-1a is defined over and
14
+ * what the Python half hashes too. The natural idiom on each side disagrees:
15
+ * `charCodeAt` walks UTF-16 units and Python's `ord` walks code points, so an
16
+ * id outside the BMP hashed differently on the two runtimes - measured, "𝕏id"
17
+ * tinted rose in the browser and peach on the server (DEF-52). Bytes are the
18
+ * one reading both languages spell naturally, so the drift cannot come back
19
+ * the next time somebody rewrites either loop. Every real conversation id is
20
+ * ASCII, where all three readings coincide, so no existing tab changes colour. */
12
21
  export declare function fnv1aColour(sessionId: string): string;
13
22
  /** Colour the user set by hand on a terminal tab, or null.
14
23
  *
15
24
  * The colourful-tab extension stores its right-click menu choices as
16
25
  * `{ "terminal:<session name>": <index into TAB_COLOUR_IDS> }`. Reading it is
17
26
  * what makes "set the colour on the tab" register as the conversation's
18
- * colour for assistants whose CLI has no colour concept at all. */
27
+ * colour - for every assistant, including one whose CLI has a colour concept
28
+ * of its own. */
19
29
  export declare function readUserSetTabColour(terminalName: string): string | null;
20
30
  /** Resolve the ladder for one conversation. `userSet` is this extension's own
21
31
  * store, `native` the conversation's own colour (only ever populated by a
@@ -34,24 +44,107 @@ export declare function sessionForCwds(cwds: string[], sessions: ISession[]): IS
34
44
  * colours survive a reload and one assistant's colours can never be read or
35
45
  * written through another's routes. Reads are cached in memory and refreshed
36
46
  * with the session poll; writes go straight through and update the cache.
47
+ *
48
+ * Every request this store makes is queued behind the last - see `_run`. That
49
+ * one rule is what keeps the class small: with only one of its requests ever on
50
+ * the wire, no answer can be older than something the cache has already taken,
51
+ * so none of the ordering questions this class used to carry can be asked.
37
52
  */
38
53
  export declare class ColourStore {
39
54
  constructor(providerId: string, serverSettings: ServerConnection.ISettings);
40
55
  /** User-set colour for a conversation, from the in-memory cache. */
41
56
  get(sessionId: string | null | undefined): string | null;
57
+ /** Whether this conversation's colour is the user's own choice rather than
58
+ * one it inherited at fork time. Only a hand-set colour is offered for
59
+ * release: dropping an inherited one would un-inherit the branch. */
60
+ isOverride(sessionId: string | null | undefined): boolean;
61
+ /** Whether a write against this conversation is still unanswered - queued
62
+ * behind an earlier request of this store's, or on the wire.
63
+ *
64
+ * Read by the release affordance - see the filter in `_openContextMenu` for
65
+ * why it must not offer a colour still being written. Counted from the CALL
66
+ * rather than from the moment the request goes out: with the store
67
+ * serialised (DEF-30) a capture can sit in the queue before it is sent, and a
68
+ * release offered in that window is undone by the capture landing after it.
69
+ *
70
+ * Not consulted by the capture gate - the cache holds only confirmed values,
71
+ * so reading it is already the confirmation. */
72
+ isPending(sessionId: string | null | undefined): boolean;
42
73
  /** Reload the whole store. Failures leave the previous cache in place - a
43
- * momentarily unreachable server must not clear every user colour. */
74
+ * momentarily unreachable server must not clear every user colour.
75
+ *
76
+ * The answer is taken whole. It is the only answer whose snapshot the caller
77
+ * ordered, and nothing this store wrote can be missing from it: the GET is
78
+ * not sent until every earlier write of this store's has been answered. */
44
79
  load(): Promise<void>;
80
+ /** Queue one request behind every request this store has already issued, and
81
+ * answer what it answers.
82
+ *
83
+ * This is the store's entire ordering discipline (DEF-30). Only one of its
84
+ * requests is ever on the wire, so an answer can never describe a moment
85
+ * older than one the cache has already taken. The monotonic write counter,
86
+ * the per-id record of the latest write and the reload generation that used
87
+ * to live here could only ever describe interleavings that are now
88
+ * unconstructible.
89
+ *
90
+ * A failed request must not poison the queue - one refused write stopping
91
+ * every later load, capture and release for the rest of the session is the
92
+ * same defect this change removes, relocated. Two things hold that, and only
93
+ * the first is reachable by test: each op catches its own failure and answers
94
+ * false, so no link rejects today; and the tail waits for SETTLEMENT rather
95
+ * than success, so `_chain` still cannot reject if a later edit puts throwing
96
+ * code outside an op's own `try` - `forget`'s filter already sits there.
97
+ *
98
+ * `requestAPI` bounds every request at `REQUEST_TIMEOUT_MS` (DEF-72), so a
99
+ * queued op waits a bounded time even against a server that never answers.
100
+ * The ceiling is per request, so K ops queued behind a dead server wait K
101
+ * times it, not it.
102
+ *
103
+ * An op must never call `load`, `set` or `forget` - it would wait on a chain
104
+ * that is waiting on it. `inherit` delegates to `set` from OUTSIDE the queue,
105
+ * which is why it is not itself queued. */
106
+ private _run;
107
+ /** Open a write against one or more ids. Counted, not flagged: two writes to
108
+ * one id can be queued at once, and a flag would let the first to settle
109
+ * clear the second's guard. */
110
+ private _openWrite;
111
+ /** Close it. */
112
+ private _closeWrite;
45
113
  /** Record a colour as this conversation's own. Passing null drops the entry,
46
- * which is also how a deleted conversation leaves no orphan key behind. */
47
- set(sessionId: string, colour: string | null): Promise<void>;
48
- /** Drop stored colours for conversations that no longer exist. */
49
- forget(sessionIds: string[]): Promise<void>;
114
+ * which is also how a deleted conversation leaves no orphan key behind.
115
+ * `handSet` false marks it inherited - written on a fork's behalf rather
116
+ * than chosen on its tab.
117
+ *
118
+ * Answers whether the store now HOLDS the colour, which is what
119
+ * `reconcileColours` gates its paint on.
120
+ *
121
+ * The cache is written only once the server has confirmed. Writing it
122
+ * eagerly and undoing that on failure looks cheaper and is not: nothing here
123
+ * reads the cache before this promise settles, while an unconfirmed entry is
124
+ * indistinguishable from a stored one to every reader - which is exactly the
125
+ * mistake the gate above exists to catch.
126
+ *
127
+ * "Did not throw" is not the test - the answer carries the whole store, so
128
+ * it is checked for the value that was just written. A server that accepted
129
+ * the request and then failed to persist it would otherwise be believed. */
130
+ set(sessionId: string, colour: string | null, handSet?: boolean): Promise<boolean>;
131
+ /** Drop stored colours - conversations that no longer exist, or overrides
132
+ * the user released. One request for the whole list, so a release is never
133
+ * left half applied. Same contract as `set`: the answer is checked for the
134
+ * absence of every id, and the cache is touched only once it agrees. */
135
+ forget(sessionIds: string[]): Promise<boolean>;
50
136
  /** Copy the parent's EFFECTIVE colour onto a fresh branch, so a branch of a
51
137
  * branch inherits whatever its immediate parent actually shows, user-set
52
- * override included. Called at the fork sites, once the new id is known. */
138
+ * override included. Called at the fork sites, once the new id is known.
139
+ */
53
140
  inherit(parentColour: string | null, childId: string): Promise<void>;
54
141
  private readonly _providerId;
55
142
  private readonly _serverSettings;
56
143
  private _colours;
144
+ private _overrides;
145
+ /** Unanswered writes per id, counted from the call. Read by the release
146
+ * affordance, which must not offer a colour still being written. */
147
+ private _pending;
148
+ /** Tail of this store's request queue - see `_run`. Never rejects. */
149
+ private _chain;
57
150
  }
@@ -29,11 +29,20 @@ const TAB_COLOUR_STORAGE_KEY = 'jupyterlab-colourful-tab-colours';
29
29
  /** Map a conversation id onto one of the six colour ids. FNV-1a (32-bit) - a
30
30
  * stable string hash with good avalanche on short hex-ish ids; `Math.imul`
31
31
  * keeps the multiplication in 32-bit integer semantics. The same id always
32
- * yields the same colour, on every reload and in every browser. */
32
+ * yields the same colour, on every reload and in every browser.
33
+ *
34
+ * Hashed over the id's UTF-8 BYTES, which is what FNV-1a is defined over and
35
+ * what the Python half hashes too. The natural idiom on each side disagrees:
36
+ * `charCodeAt` walks UTF-16 units and Python's `ord` walks code points, so an
37
+ * id outside the BMP hashed differently on the two runtimes - measured, "𝕏id"
38
+ * tinted rose in the browser and peach on the server (DEF-52). Bytes are the
39
+ * one reading both languages spell naturally, so the drift cannot come back
40
+ * the next time somebody rewrites either loop. Every real conversation id is
41
+ * ASCII, where all three readings coincide, so no existing tab changes colour. */
33
42
  export function fnv1aColour(sessionId) {
34
43
  let hash = 0x811c9dc5; // FNV offset basis
35
- for (let i = 0; i < sessionId.length; i++) {
36
- hash ^= sessionId.charCodeAt(i);
44
+ for (const byte of new TextEncoder().encode(sessionId)) {
45
+ hash ^= byte;
37
46
  hash = Math.imul(hash, 0x01000193); // FNV prime
38
47
  }
39
48
  return TAB_COLOUR_IDS[(hash >>> 0) % TAB_COLOUR_IDS.length];
@@ -43,7 +52,8 @@ export function fnv1aColour(sessionId) {
43
52
  * The colourful-tab extension stores its right-click menu choices as
44
53
  * `{ "terminal:<session name>": <index into TAB_COLOUR_IDS> }`. Reading it is
45
54
  * what makes "set the colour on the tab" register as the conversation's
46
- * colour for assistants whose CLI has no colour concept at all. */
55
+ * colour - for every assistant, including one whose CLI has a colour concept
56
+ * of its own. */
47
57
  export function readUserSetTabColour(terminalName) {
48
58
  var _a;
49
59
  try {
@@ -106,10 +116,21 @@ export function sessionForCwds(cwds, sessions) {
106
116
  * colours survive a reload and one assistant's colours can never be read or
107
117
  * written through another's routes. Reads are cached in memory and refreshed
108
118
  * with the session poll; writes go straight through and update the cache.
119
+ *
120
+ * Every request this store makes is queued behind the last - see `_run`. That
121
+ * one rule is what keeps the class small: with only one of its requests ever on
122
+ * the wire, no answer can be older than something the cache has already taken,
123
+ * so none of the ordering questions this class used to carry can be asked.
109
124
  */
110
125
  export class ColourStore {
111
126
  constructor(providerId, serverSettings) {
112
127
  this._colours = new Map();
128
+ this._overrides = new Set();
129
+ /** Unanswered writes per id, counted from the call. Read by the release
130
+ * affordance, which must not offer a colour still being written. */
131
+ this._pending = new Map();
132
+ /** Tail of this store's request queue - see `_run`. Never rejects. */
133
+ this._chain = Promise.resolve();
113
134
  this._providerId = providerId;
114
135
  this._serverSettings = serverSettings;
115
136
  }
@@ -121,70 +142,217 @@ export class ColourStore {
121
142
  }
122
143
  return (_a = this._colours.get(sessionId)) !== null && _a !== void 0 ? _a : null;
123
144
  }
145
+ /** Whether this conversation's colour is the user's own choice rather than
146
+ * one it inherited at fork time. Only a hand-set colour is offered for
147
+ * release: dropping an inherited one would un-inherit the branch. */
148
+ isOverride(sessionId) {
149
+ return !!sessionId && this._overrides.has(sessionId);
150
+ }
151
+ /** Whether a write against this conversation is still unanswered - queued
152
+ * behind an earlier request of this store's, or on the wire.
153
+ *
154
+ * Read by the release affordance - see the filter in `_openContextMenu` for
155
+ * why it must not offer a colour still being written. Counted from the CALL
156
+ * rather than from the moment the request goes out: with the store
157
+ * serialised (DEF-30) a capture can sit in the queue before it is sent, and a
158
+ * release offered in that window is undone by the capture landing after it.
159
+ *
160
+ * Not consulted by the capture gate - the cache holds only confirmed values,
161
+ * so reading it is already the confirmation. */
162
+ isPending(sessionId) {
163
+ var _a;
164
+ return !!sessionId && ((_a = this._pending.get(sessionId)) !== null && _a !== void 0 ? _a : 0) > 0;
165
+ }
124
166
  /** Reload the whole store. Failures leave the previous cache in place - a
125
- * momentarily unreachable server must not clear every user colour. */
167
+ * momentarily unreachable server must not clear every user colour.
168
+ *
169
+ * The answer is taken whole. It is the only answer whose snapshot the caller
170
+ * ordered, and nothing this store wrote can be missing from it: the GET is
171
+ * not sent until every earlier write of this store's has been answered. */
126
172
  async load() {
127
- var _a;
128
- try {
129
- const data = await requestProvider(this._providerId, 'colours', this._serverSettings, { cache: 'no-store' });
130
- const next = new Map();
131
- for (const [id, colour] of Object.entries((_a = data.colours) !== null && _a !== void 0 ? _a : {})) {
132
- if (typeof colour === 'string' && colour) {
133
- next.set(id, colour);
173
+ return this._run(async () => {
174
+ var _a, _b;
175
+ try {
176
+ const data = await requestProvider(this._providerId, 'colours', this._serverSettings, { cache: 'no-store' });
177
+ const next = new Map();
178
+ for (const [id, colour] of Object.entries((_a = data.colours) !== null && _a !== void 0 ? _a : {})) {
179
+ if (typeof colour === 'string' && colour) {
180
+ next.set(id, colour);
181
+ }
134
182
  }
183
+ // A store with no hand-set ids named predates the distinction, and
184
+ // nothing in it counts as hand-set - `colour_store._read` carries the
185
+ // asymmetry argument for that choice.
186
+ this._overrides = new Set(((_b = data.overrides) !== null && _b !== void 0 ? _b : []).filter(id => next.has(id)));
187
+ this._colours = next;
188
+ }
189
+ catch (_err) {
190
+ // Keep the cache; the next poll retries.
135
191
  }
136
- this._colours = next;
192
+ });
193
+ }
194
+ /** Queue one request behind every request this store has already issued, and
195
+ * answer what it answers.
196
+ *
197
+ * This is the store's entire ordering discipline (DEF-30). Only one of its
198
+ * requests is ever on the wire, so an answer can never describe a moment
199
+ * older than one the cache has already taken. The monotonic write counter,
200
+ * the per-id record of the latest write and the reload generation that used
201
+ * to live here could only ever describe interleavings that are now
202
+ * unconstructible.
203
+ *
204
+ * A failed request must not poison the queue - one refused write stopping
205
+ * every later load, capture and release for the rest of the session is the
206
+ * same defect this change removes, relocated. Two things hold that, and only
207
+ * the first is reachable by test: each op catches its own failure and answers
208
+ * false, so no link rejects today; and the tail waits for SETTLEMENT rather
209
+ * than success, so `_chain` still cannot reject if a later edit puts throwing
210
+ * code outside an op's own `try` - `forget`'s filter already sits there.
211
+ *
212
+ * `requestAPI` bounds every request at `REQUEST_TIMEOUT_MS` (DEF-72), so a
213
+ * queued op waits a bounded time even against a server that never answers.
214
+ * The ceiling is per request, so K ops queued behind a dead server wait K
215
+ * times it, not it.
216
+ *
217
+ * An op must never call `load`, `set` or `forget` - it would wait on a chain
218
+ * that is waiting on it. `inherit` delegates to `set` from OUTSIDE the queue,
219
+ * which is why it is not itself queued. */
220
+ _run(op) {
221
+ const result = this._chain.then(op);
222
+ const settled = () => undefined;
223
+ this._chain = result.then(settled, settled);
224
+ return result;
225
+ }
226
+ /** Open a write against one or more ids. Counted, not flagged: two writes to
227
+ * one id can be queued at once, and a flag would let the first to settle
228
+ * clear the second's guard. */
229
+ _openWrite(ids) {
230
+ var _a;
231
+ for (const id of ids) {
232
+ this._pending.set(id, ((_a = this._pending.get(id)) !== null && _a !== void 0 ? _a : 0) + 1);
137
233
  }
138
- catch (_err) {
139
- // Keep the cache; the next poll retries.
234
+ }
235
+ /** Close it. */
236
+ _closeWrite(ids) {
237
+ var _a;
238
+ for (const id of ids) {
239
+ const open = ((_a = this._pending.get(id)) !== null && _a !== void 0 ? _a : 1) - 1;
240
+ if (open > 0) {
241
+ this._pending.set(id, open);
242
+ }
243
+ else {
244
+ this._pending.delete(id);
245
+ }
140
246
  }
141
247
  }
142
248
  /** Record a colour as this conversation's own. Passing null drops the entry,
143
- * which is also how a deleted conversation leaves no orphan key behind. */
144
- async set(sessionId, colour) {
249
+ * which is also how a deleted conversation leaves no orphan key behind.
250
+ * `handSet` false marks it inherited - written on a fork's behalf rather
251
+ * than chosen on its tab.
252
+ *
253
+ * Answers whether the store now HOLDS the colour, which is what
254
+ * `reconcileColours` gates its paint on.
255
+ *
256
+ * The cache is written only once the server has confirmed. Writing it
257
+ * eagerly and undoing that on failure looks cheaper and is not: nothing here
258
+ * reads the cache before this promise settles, while an unconfirmed entry is
259
+ * indistinguishable from a stored one to every reader - which is exactly the
260
+ * mistake the gate above exists to catch.
261
+ *
262
+ * "Did not throw" is not the test - the answer carries the whole store, so
263
+ * it is checked for the value that was just written. A server that accepted
264
+ * the request and then failed to persist it would otherwise be believed. */
265
+ async set(sessionId, colour, handSet = true) {
145
266
  if (!sessionId) {
146
- return;
147
- }
148
- if (colour) {
149
- this._colours.set(sessionId, colour);
150
- }
151
- else {
152
- this._colours.delete(sessionId);
153
- }
154
- try {
155
- await requestProvider(this._providerId, 'colours', this._serverSettings, {
156
- method: 'POST',
157
- body: JSON.stringify({ session_id: sessionId, colour })
158
- });
159
- }
160
- catch (_err) {
161
- // The cache already reflects the intent; the next load reconciles.
267
+ return false;
162
268
  }
269
+ const ids = [sessionId];
270
+ // Marked before the op is queued, not inside it - the queue window is part
271
+ // of the window in which no release may be offered. See `isPending`.
272
+ this._openWrite(ids);
273
+ return this._run(async () => {
274
+ var _a;
275
+ try {
276
+ const data = await requestProvider(this._providerId, 'colours', this._serverSettings, {
277
+ method: 'POST',
278
+ body: JSON.stringify({
279
+ session_id: sessionId,
280
+ colour,
281
+ hand_set: handSet
282
+ })
283
+ });
284
+ if (((_a = data.colours) !== null && _a !== void 0 ? _a : {})[sessionId] !== (colour !== null && colour !== void 0 ? colour : undefined)) {
285
+ return false;
286
+ }
287
+ // Only this conversation's entry is taken from the answer. The rest of
288
+ // the map is a snapshot of a moment this caller did not order, and a
289
+ // reload is how the caller orders one.
290
+ if (colour) {
291
+ this._colours.set(sessionId, colour);
292
+ }
293
+ else {
294
+ this._colours.delete(sessionId);
295
+ }
296
+ this._overrides.delete(sessionId);
297
+ if (colour && handSet) {
298
+ this._overrides.add(sessionId);
299
+ }
300
+ return true;
301
+ }
302
+ catch (_err) {
303
+ return false;
304
+ }
305
+ finally {
306
+ this._closeWrite(ids);
307
+ }
308
+ });
163
309
  }
164
- /** Drop stored colours for conversations that no longer exist. */
310
+ /** Drop stored colours - conversations that no longer exist, or overrides
311
+ * the user released. One request for the whole list, so a release is never
312
+ * left half applied. Same contract as `set`: the answer is checked for the
313
+ * absence of every id, and the cache is touched only once it agrees. */
165
314
  async forget(sessionIds) {
166
- const present = sessionIds.filter(id => this._colours.has(id));
167
- if (present.length === 0) {
168
- return;
169
- }
170
- present.forEach(id => this._colours.delete(id));
171
- try {
172
- await requestProvider(this._providerId, 'colours', this._serverSettings, {
173
- method: 'DELETE',
174
- body: JSON.stringify({ session_ids: present })
175
- });
176
- }
177
- catch (_err) {
178
- // Same as `set`: the cache is authoritative until the next load.
179
- }
315
+ return this._run(async () => {
316
+ // Filtered INSIDE the op, not at the call: a capture queued ahead of this
317
+ // release has landed by the time this runs, so the cache is the current
318
+ // answer to "does the store hold one". `isPending` still counts, but only
319
+ // for a write queued BEHIND this release - that id is in no cache yet and
320
+ // would otherwise be left out of a request that is going out anyway.
321
+ const present = sessionIds.filter(id => this._colours.has(id) || this.isPending(id));
322
+ if (present.length === 0) {
323
+ return true;
324
+ }
325
+ this._openWrite(present);
326
+ try {
327
+ const data = await requestProvider(this._providerId, 'colours', this._serverSettings, {
328
+ method: 'DELETE',
329
+ body: JSON.stringify({ session_ids: present })
330
+ });
331
+ if (present.some(id => { var _a; return ((_a = data.colours) !== null && _a !== void 0 ? _a : {})[id] !== undefined; })) {
332
+ return false;
333
+ }
334
+ for (const id of present) {
335
+ this._colours.delete(id);
336
+ this._overrides.delete(id);
337
+ }
338
+ return true;
339
+ }
340
+ catch (_err) {
341
+ return false;
342
+ }
343
+ finally {
344
+ this._closeWrite(present);
345
+ }
346
+ });
180
347
  }
181
348
  /** Copy the parent's EFFECTIVE colour onto a fresh branch, so a branch of a
182
349
  * branch inherits whatever its immediate parent actually shows, user-set
183
- * override included. Called at the fork sites, once the new id is known. */
350
+ * override included. Called at the fork sites, once the new id is known.
351
+ */
184
352
  async inherit(parentColour, childId) {
185
353
  if (!parentColour || !childId) {
186
354
  return;
187
355
  }
188
- await this.set(childId, parentColour);
356
+ await this.set(childId, parentColour, false);
189
357
  }
190
358
  }
package/lib/core/icons.js CHANGED
@@ -40,8 +40,14 @@ export const removeIcon = new LabIcon({
40
40
  // Marks the unsafe launch variants. A shield reads as "protected", which is
41
41
  // the inverse of what these entries do, so the caution triangle carries them
42
42
  // instead. Material "warning" outline.
43
- const warningSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="13" height="13">
44
- <path class="jp-icon3" fill="#616161" d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
43
+ //
44
+ // 16x16 and `jp-icon-warn0`, like every other icon in these menus and unlike
45
+ // them in colour: authored at 13x13 in `jp-icon3` it rendered smaller than
46
+ // its neutral siblings AND in the identical grey, measured rgb(97,97,97) in
47
+ // the light theme and rgb(189,189,189) in the dark one - the one glyph whose
48
+ // whole job is to say "this differs" was the least conspicuous in the menu.
49
+ const warningSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
50
+ <path class="jp-icon-warn0" fill="#f57c00" d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
45
51
  </svg>`;
46
52
  export const warningIcon = new LabIcon({
47
53
  name: `${ICON_PREFIX}:warning`,
@@ -1,4 +1,12 @@
1
1
  import { ILaunchMode } from './types';
2
+ /** A resolved launch mode, with what to call it and whether to warn. */
3
+ export interface IResolvedLaunchMode {
4
+ mode: ILaunchMode;
5
+ /** What the menu should call it - the value, or the mode's own label. */
6
+ label: string;
7
+ /** Whether this widens what the assistant may do without asking. */
8
+ unsafe: boolean;
9
+ }
2
10
  /**
3
11
  * The single launch-mode token for one action, or undefined for a plain launch.
4
12
  *
@@ -11,3 +19,14 @@ import { ILaunchMode } from './types';
11
19
  * the CLI would do unasked.
12
20
  */
13
21
  export declare function resolveLaunchMode(launchModes: ILaunchMode[], values: Record<string, boolean | string>, force?: string): string | undefined;
22
+ /**
23
+ * The mode a PLAIN launch would carry right now, as a descriptor entry.
24
+ *
25
+ * The panel marks and labels its plain menu items from this. A mode left on in
26
+ * settings applies to the plain items too, so deciding the warning glyph from
27
+ * the menu's `force` argument instead puts it on the safer-looking of two
28
+ * entries that build an identical launch - and leaves the one the user clicks
29
+ * unmarked. Answers the entry rather than the token so the caller can use the
30
+ * assistant's own wording.
31
+ */
32
+ export declare function resolvedLaunchModeEntry(launchModes: ILaunchMode[], values: Record<string, boolean | string>): IResolvedLaunchMode | null;
package/lib/core/modes.js CHANGED
@@ -34,3 +34,39 @@ export function resolveLaunchMode(launchModes, values, force) {
34
34
  }
35
35
  return undefined;
36
36
  }
37
+ /**
38
+ * The mode a PLAIN launch would carry right now, as a descriptor entry.
39
+ *
40
+ * The panel marks and labels its plain menu items from this. A mode left on in
41
+ * settings applies to the plain items too, so deciding the warning glyph from
42
+ * the menu's `force` argument instead puts it on the safer-looking of two
43
+ * entries that build an identical launch - and leaves the one the user clicks
44
+ * unmarked. Answers the entry rather than the token so the caller can use the
45
+ * assistant's own wording.
46
+ */
47
+ export function resolvedLaunchModeEntry(launchModes, values) {
48
+ var _a, _b;
49
+ const token = resolveLaunchMode(launchModes, values);
50
+ if (!token) {
51
+ return null;
52
+ }
53
+ const [id, value] = token.split('=');
54
+ const mode = launchModes.find(m => m.id === id);
55
+ if (!mode) {
56
+ return null;
57
+ }
58
+ // A boolean mode with a menu label IS the assistant's skip-approval switch;
59
+ // an enum only widens on the values its provider declared. `plan` resolves
60
+ // to a mode and is stricter than the default, so "resolved at all" is the
61
+ // wrong question to ask about danger.
62
+ const unsafe = value
63
+ ? ((_a = mode.unsafeValues) !== null && _a !== void 0 ? _a : []).indexOf(value) !== -1
64
+ : !!mode.menuLabel;
65
+ // Name the VALUE where there is one - three enum values sharing the mode's
66
+ // title render as one indistinguishable label.
67
+ return {
68
+ mode,
69
+ label: (_b = value !== null && value !== void 0 ? value : mode.menuLabel) !== null && _b !== void 0 ? _b : mode.title,
70
+ unsafe
71
+ };
72
+ }