jupyterlab_ai_code_assistants_extension 0.6.15

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 (57) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +44 -0
  3. package/lib/core/colour.d.ts +57 -0
  4. package/lib/core/colour.js +190 -0
  5. package/lib/core/icons.d.ts +12 -0
  6. package/lib/core/icons.js +99 -0
  7. package/lib/core/modes.d.ts +13 -0
  8. package/lib/core/modes.js +36 -0
  9. package/lib/core/panel.d.ts +233 -0
  10. package/lib/core/panel.js +1673 -0
  11. package/lib/core/popup.d.ts +40 -0
  12. package/lib/core/popup.js +404 -0
  13. package/lib/core/registry.d.ts +24 -0
  14. package/lib/core/registry.js +48 -0
  15. package/lib/core/request.d.ts +25 -0
  16. package/lib/core/request.js +64 -0
  17. package/lib/core/terminals.d.ts +129 -0
  18. package/lib/core/terminals.js +344 -0
  19. package/lib/core/types.d.ts +269 -0
  20. package/lib/core/types.js +6 -0
  21. package/lib/index.d.ts +3 -0
  22. package/lib/index.js +277 -0
  23. package/lib/providers/claude.d.ts +3 -0
  24. package/lib/providers/claude.js +88 -0
  25. package/lib/providers/codex.d.ts +3 -0
  26. package/lib/providers/codex.js +79 -0
  27. package/lib/providers/gemini.d.ts +2 -0
  28. package/lib/providers/gemini.js +73 -0
  29. package/lib/providers/index.d.ts +3 -0
  30. package/lib/providers/index.js +16 -0
  31. package/lib/providers/kimi.d.ts +23 -0
  32. package/lib/providers/kimi.js +139 -0
  33. package/package.json +169 -0
  34. package/schema/plugin.json +93 -0
  35. package/src/__tests__/colour.spec.ts +153 -0
  36. package/src/__tests__/core-neutrality.spec.ts +86 -0
  37. package/src/__tests__/labels.spec.ts +89 -0
  38. package/src/__tests__/launch-mode.spec.ts +53 -0
  39. package/src/__tests__/registry.spec.ts +178 -0
  40. package/src/core/colour.ts +224 -0
  41. package/src/core/icons.ts +121 -0
  42. package/src/core/modes.ts +45 -0
  43. package/src/core/panel.ts +2020 -0
  44. package/src/core/popup.ts +475 -0
  45. package/src/core/registry.ts +60 -0
  46. package/src/core/request.ts +96 -0
  47. package/src/core/terminals.ts +446 -0
  48. package/src/core/types.ts +305 -0
  49. package/src/index.ts +356 -0
  50. package/src/providers/claude.ts +102 -0
  51. package/src/providers/codex.ts +89 -0
  52. package/src/providers/gemini.ts +80 -0
  53. package/src/providers/index.ts +21 -0
  54. package/src/providers/kimi.ts +155 -0
  55. package/style/base.css +888 -0
  56. package/style/index.css +1 -0
  57. package/style/index.js +1 -0
@@ -0,0 +1,305 @@
1
+ // Shared vocabulary of the provider core. Nothing here names an assistant:
2
+ // every divergence between the registered providers is expressed as a
3
+ // capability flag on the descriptor, or as an optional hook the provider
4
+ // module supplies. Adding an assistant adds a descriptor, never a branch in
5
+ // core code.
6
+
7
+ /** How a conversation is branched.
8
+ *
9
+ * - `native-flag` - the CLI forks in-process from a flag pair, and the new
10
+ * conversation id is minted by the frontend, so the launch carries it and
11
+ * the branch is watched for until its store entry materialises
12
+ * - `native-command` - the CLI owns the fork verb AND mints the id itself, so
13
+ * the id has to be discovered by diffing the branch list after launch
14
+ * - `server-copy` - the CLI has no fork at all; the server copies the store
15
+ * entry, answers with the new id, and the launch resumes that id
16
+ */
17
+ export type ForkStrategy = 'native-flag' | 'native-command' | 'server-copy';
18
+
19
+ /** Where a conversation's DEFAULT tab tint comes from. A user-set colour in
20
+ * the extension's own write-back store always wins over all three. */
21
+ export type ColourSource = 'native' | 'derived' | 'none';
22
+
23
+ /** Who owns a branched conversation's name.
24
+ *
25
+ * - `launch-flag` - the CLI is told the name at launch and stamps it itself
26
+ * - `server-side` - the server writes the name into the copied store entry
27
+ * - `none` - the assistant has no naming surface, so no name is asked for
28
+ */
29
+ export type NamingStrategy = 'launch-flag' | 'server-side' | 'none';
30
+
31
+ /** One launch-behaviour setting of an assistant, in that assistant's own
32
+ * terminology. `boolean` modes are the unsafe/skip-approval switches: they get
33
+ * a settings toggle (off by default) AND a context-menu launch variant.
34
+ * `enum` modes are settings-only choices passed through on every launch. */
35
+ export interface ILaunchMode {
36
+ /** Settings key under `providers.<id>.` and launch-payload key. Uses the
37
+ * assistant's own terminology, never a normalised core name. */
38
+ id: string;
39
+ /** Settings title shown in the JupyterLab settings editor. */
40
+ title: string;
41
+ /** Settings description. */
42
+ description: string;
43
+ kind: 'boolean' | 'enum';
44
+ /** Allowed values, `enum` modes only. */
45
+ values?: string[];
46
+ /** Default value. Boolean modes default to `false` without exception. */
47
+ default: boolean | string;
48
+ /** Menu label for the launch variant this mode produces, e.g.
49
+ * `Skip Permissions`. Boolean modes only; absent means settings-only. */
50
+ menuLabel?: string;
51
+ }
52
+
53
+ /** Everything the core needs to know about one assistant. Pure data - no
54
+ * JupyterLab imports - so the settings-schema generator can read it under
55
+ * plain Node from the compiled barrel. */
56
+ export interface IProviderDescriptor {
57
+ /** Stable id. Drives the route namespace, widget id, command namespace,
58
+ * settings keys and localStorage keys. */
59
+ id: string;
60
+ /** Display label, e.g. used in settings and dialogs. */
61
+ label: string;
62
+ /** Panel title and tab caption. */
63
+ panelTitle: string;
64
+ /** LabIcon name for the panel icon. Registered by the core registry. */
65
+ iconName: string;
66
+ /** SVG source for that icon. Kept as a string so the descriptor carries no
67
+ * JupyterLab dependency; the registry builds the LabIcon. */
68
+ iconSvg: string;
69
+ /** Binary probed on PATH server-side. Absence disables this provider only. */
70
+ cliBinary: string;
71
+ forkStrategy: ForkStrategy;
72
+ colourSource: ColourSource;
73
+ namingStrategy: NamingStrategy;
74
+ /** The CLI starts a brand-new conversation under an id we give it, so a
75
+ * fresh session is identifiable from its argv on the first poll instead of
76
+ * being an unknown terminal until it writes its store entry. Independent of
77
+ * `forkStrategy` - an assistant can accept an id for a new session and still
78
+ * have no fork verb at all. */
79
+ mintsNewSessionId: boolean;
80
+ launchModes: ILaunchMode[];
81
+ /** Sessions can be held under remote control - renders the indicator dot. */
82
+ hasRemoteControl: boolean;
83
+ /** Sessions can be owned by a live background agent - renders the chip and
84
+ * asks the branch endpoint for agent state. */
85
+ hasBgAgents: boolean;
86
+ /** Projects can show a live-process indicator. */
87
+ hasLiveProcess: boolean;
88
+ /** JupyterLab plugin id of the retired standalone extension this provider
89
+ * replaces, without the `:plugin` suffix. Present only for a provider that
90
+ * had one: if that plugin is still installed the core suppresses this
91
+ * provider's panel rather than docking a second one for the same assistant.
92
+ */
93
+ legacyPluginId?: string;
94
+ }
95
+
96
+ /** One project row. Fields past `extra_sessions` are capability-gated: a
97
+ * provider whose descriptor does not claim the capability simply omits them,
98
+ * and the panel renders nothing for them. */
99
+ export interface ISession {
100
+ project_path: string;
101
+ /** The provider's own project key. Opaque to the core - it is only ever
102
+ * echoed back to that provider's routes. */
103
+ encoded_path: string;
104
+ session_id: string;
105
+ name: string;
106
+ name_source: 'session' | 'basename';
107
+ message_count: number;
108
+ /** Last activity, epoch milliseconds. */
109
+ file_mtime: number;
110
+ git_branch: string | null;
111
+ favourite: boolean;
112
+ /** Conversations of this project beyond the current one. */
113
+ extra_sessions: number;
114
+ summary?: string;
115
+ first_prompt?: string;
116
+ created?: string | number | null;
117
+ modified?: string | number | null;
118
+ /** The conversation's own colour, for `colourSource: 'native'` providers.
119
+ * One of the six colour ids, or null. */
120
+ colour?: string | null;
121
+ /** Remote control is active on this conversation (`hasRemoteControl`). */
122
+ remote_control?: boolean;
123
+ /** Short id of the live background agent holding this conversation
124
+ * (`hasBgAgents`), or null. Display only - the server picks the launch verb
125
+ * itself, where it cannot be stale. */
126
+ bg_id?: string | null;
127
+ /** A process of this assistant is running in this project
128
+ * (`hasLiveProcess`). */
129
+ live?: boolean;
130
+ /** Model the conversation last ran on, when the store records one. */
131
+ model?: string | null;
132
+ tokens_used?: number;
133
+ }
134
+
135
+ export interface ISessionsListResponse {
136
+ sessions: ISession[];
137
+ }
138
+
139
+ /** One registered provider as the server sees it. */
140
+ export interface IProviderStatus {
141
+ id: string;
142
+ label: string;
143
+ /** The `providers.<id>.enabled` setting as the server last read it. */
144
+ enabled: boolean;
145
+ cli_path: string | null;
146
+ /** Enabled AND binary present - the only state that docks a panel. */
147
+ available: boolean;
148
+ }
149
+
150
+ export interface IStatusResponse {
151
+ root_dir: string;
152
+ providers: IProviderStatus[];
153
+ /** JupyterLab's own move-to-trash setting, read server-side, so dialogs say
154
+ * which of the two a click means rather than reciting both. */
155
+ delete_to_trash?: boolean;
156
+ }
157
+
158
+ export interface IFavouriteRequest {
159
+ project_path: string;
160
+ favourite: boolean;
161
+ }
162
+
163
+ export interface IFavouriteResponse {
164
+ favourites: string[];
165
+ }
166
+
167
+ /** Destructive endpoints answer with the count AND the ids that actually went,
168
+ * because a shortfall is not a failure: whatever was disposed of is gone, so
169
+ * the count has to reach the user - and only the ids named here may have their
170
+ * stored colour dropped, or a conversation whose deletion was refused loses its
171
+ * tint. */
172
+ export interface IDisposalReport {
173
+ removed_count: number;
174
+ removed_ids: string[];
175
+ }
176
+
177
+ export type IRemoveResponse = IDisposalReport;
178
+
179
+ export type ICleanupResponse = IDisposalReport;
180
+
181
+ export type IDeleteSessionsResponse = IDisposalReport;
182
+
183
+ export interface IBranch {
184
+ session_id: string;
185
+ file_mtime: number;
186
+ label: string;
187
+ /** Live background agent owning this branch (`hasBgAgents`), or null.
188
+ * Populated only when the fetch asked for it. */
189
+ bg_id?: string | null;
190
+ }
191
+
192
+ export interface IBranchesResponse {
193
+ current: string;
194
+ total: number;
195
+ branches: IBranch[];
196
+ }
197
+
198
+ export interface ISwitchResponse {
199
+ requested: string;
200
+ current: string | null;
201
+ }
202
+
203
+ export interface IForkRequest {
204
+ encoded_path: string;
205
+ session_id: string;
206
+ name?: string;
207
+ }
208
+
209
+ export interface IForkResponse {
210
+ session_id: string;
211
+ }
212
+
213
+ export interface ILaunchRequest {
214
+ project_path: string;
215
+ /** The project's store key. Carrying it arms the server's launch-time pin
216
+ * bookkeeping (a native-flag fork pins itself current, a new session clears
217
+ * a stale pin) and the session_not_found pre-flight - without it both are
218
+ * silently skipped. Send it whenever the session row is in hand. */
219
+ encoded_path?: string;
220
+ /** Conversation to open. Omit to start a brand-new one. */
221
+ session_id?: string;
222
+ /** Frontend-minted id for a brand-new conversation, when the CLI accepts
223
+ * one, so the terminal is identifiable from its argv on the first poll. */
224
+ new_session_id?: string;
225
+ /** Frontend-minted id for the fork (`forkStrategy: 'native-flag'`). */
226
+ fork_session_id?: string;
227
+ /** Parent conversation to fork from (`forkStrategy: 'native-command'`). */
228
+ fork_from?: string;
229
+ /** Name to stamp on the new conversation (`namingStrategy: 'launch-flag'`). */
230
+ name?: string;
231
+ /** The launch mode this action runs under, as one token in the assistant's
232
+ * own terminology: a boolean mode is its bare `ILaunchMode.id`, an enum mode
233
+ * is `<id>=<value>`. One token, not a set - the server maps a single mode to
234
+ * a single flag, and refuses any token the descriptor does not declare with
235
+ * 400 `mode_unsupported`. */
236
+ mode?: string;
237
+ }
238
+
239
+ export interface ILaunchResponse {
240
+ terminal_name: string;
241
+ }
242
+
243
+ /** What a terminal is running, as the server resolved it from `/proc`. */
244
+ export interface ITerminalProbeResponse {
245
+ terminal_name: string;
246
+ /** The assistant's binary is the pty's process. */
247
+ running: boolean;
248
+ /** Conversation the running process holds, or null when unreadable. */
249
+ session_id?: string | null;
250
+ /** That conversation's own colour (`colourSource: 'native'`). */
251
+ colour?: string | null;
252
+ /** Working directories read from `/proc`, never from `PWD`. */
253
+ cwds?: string[];
254
+ }
255
+
256
+ /** What one provider carried over from its retired standalone extension.
257
+ * `keys` are settings this extension is to apply through the settings registry,
258
+ * which the server has no writable handle on. */
259
+ export interface IMigratedProvider {
260
+ provider_id: string;
261
+ keys: Record<string, boolean | number | string>;
262
+ favourites: string[];
263
+ }
264
+
265
+ /** `POST migrate`. Idempotent - a second call reports an empty list. */
266
+ export interface IMigrateResponse {
267
+ migrated: IMigratedProvider[];
268
+ }
269
+
270
+ /** The extension's own per-provider colour store: user-set tab colours keyed
271
+ * by conversation id. */
272
+ export interface IColourStoreResponse {
273
+ colours: Record<string, string>;
274
+ }
275
+
276
+ // ------------------------------------------------------------------- hooks
277
+
278
+ /** Everything a provider hook is handed about the row it is describing. */
279
+ export interface IHookContext {
280
+ session: ISession;
281
+ descriptor: IProviderDescriptor;
282
+ }
283
+
284
+ /** Optional behaviour a provider module supplies on top of its descriptor.
285
+ * Every hook is optional - a provider that supplies none renders the plain
286
+ * shared panel. */
287
+ export interface IProviderHooks {
288
+ /** Label for the primary open action, when the provider distinguishes verbs
289
+ * (joining a live worker is not the same as resuming a dormant store entry). */
290
+ resumeLabel?: (session: ISession | null) => string;
291
+ /** Extra lines for the row tooltip. */
292
+ tooltipLines?: (ctx: IHookContext) => string[];
293
+ /** Menu label for one branch. Defaults to `<label> (<short id>) - <time>`;
294
+ * a provider whose titles are written in wide scripts overrides it to bound
295
+ * the menu by display columns rather than by code units. */
296
+ branchLabel?: (branch: IBranch, shortId: string) => string;
297
+ /** Chip appended to a branch row in the menu and the popup. */
298
+ branchBadge?: (branch: IBranch) => HTMLElement | null;
299
+ }
300
+
301
+ /** What `providers/index.ts` registers: one descriptor, optionally with hooks. */
302
+ export interface IProviderModule {
303
+ descriptor: IProviderDescriptor;
304
+ hooks?: IProviderHooks;
305
+ }
package/src/index.ts ADDED
@@ -0,0 +1,356 @@
1
+ import {
2
+ ILabShell,
3
+ ILayoutRestorer,
4
+ JupyterFrontEnd,
5
+ JupyterFrontEndPlugin
6
+ } from '@jupyterlab/application';
7
+ import { Notification } from '@jupyterlab/apputils';
8
+ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
9
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
10
+ import { ITerminalTracker } from '@jupyterlab/terminal';
11
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
12
+ import { IDisposable } from '@lumino/disposable';
13
+
14
+ import {
15
+ AssistantSessionsPanel,
16
+ PresentationMode,
17
+ commandId,
18
+ panelWidgetId
19
+ } from './core/panel';
20
+ import { LOG_PREFIX, ProviderRegistry } from './core/registry';
21
+ import { requestAPI } from './core/request';
22
+ import {
23
+ IMigrateResponse,
24
+ IProviderStatus,
25
+ IStatusResponse
26
+ } from './core/types';
27
+ import { PROVIDERS } from './providers';
28
+
29
+ const PLUGIN_ID = 'jupyterlab_ai_code_assistants_extension:plugin';
30
+ /** Sidebar rank, shared by every panel so they dock together. */
31
+ const PANEL_RANK = 600;
32
+ /** How often the server is re-asked which assistants are available, so a CLI
33
+ * installed while JupyterLab runs surfaces without a reload. */
34
+ const STATUS_INTERVAL_MS = 60_000;
35
+
36
+ type Sidebar = 'left' | 'right';
37
+
38
+ /** One live provider: its panel and the command that refreshes it. */
39
+ interface ILivePanel {
40
+ panel: AssistantSessionsPanel;
41
+ command: IDisposable;
42
+ }
43
+
44
+ const plugin: JupyterFrontEndPlugin<void> = {
45
+ id: PLUGIN_ID,
46
+ description:
47
+ 'One side panel per AI code assistant, over a shared provider registry: sessions, favourites, branching and one-click resume in a terminal.',
48
+ autoStart: true,
49
+ requires: [ILabShell],
50
+ optional: [
51
+ ILayoutRestorer,
52
+ ISettingRegistry,
53
+ ITerminalTracker,
54
+ IDefaultFileBrowser,
55
+ IColourfulTabs
56
+ ],
57
+ activate: async (
58
+ app: JupyterFrontEnd,
59
+ labShell: ILabShell,
60
+ restorer: ILayoutRestorer | null,
61
+ settingRegistry: ISettingRegistry | null,
62
+ terminalTracker: ITerminalTracker | null,
63
+ fileBrowser: IDefaultFileBrowser | null,
64
+ colourfulTabs: IColourfulTabs | null
65
+ ) => {
66
+ console.log(
67
+ 'JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!'
68
+ );
69
+
70
+ // A duplicate id throws here, at registration, rather than surfacing later
71
+ // as two panels quietly fighting over one widget id.
72
+ const registry = new ProviderRegistry(PROVIDERS);
73
+
74
+ const serverSettings = app.serviceManager.serverSettings;
75
+ const live = new Map<string, ILivePanel>();
76
+ const warnedUnknown = new Set<string>();
77
+ const warnedUnavailable = new Set<string>();
78
+
79
+ // Assistants whose retired standalone extension is STILL installed. This
80
+ // extension supersedes those three, and running both would give the user
81
+ // two panels and two command sets for one assistant - so the duplicate is
82
+ // resolved in favour of the standalone one already on screen and this
83
+ // provider stays dark: never started, so it never polls and never registers
84
+ // a command. Resolved once, at activation: a plugin cannot be installed
85
+ // into a running JupyterLab.
86
+ const suppressed = new Set<string>();
87
+ for (const descriptor of registry.descriptors) {
88
+ if (
89
+ descriptor.legacyPluginId &&
90
+ app.hasPlugin(`${descriptor.legacyPluginId}:plugin`)
91
+ ) {
92
+ suppressed.add(descriptor.id);
93
+ }
94
+ }
95
+ if (suppressed.size > 0) {
96
+ const packages = registry.descriptors
97
+ .filter(d => suppressed.has(d.id))
98
+ .map(d => d.legacyPluginId)
99
+ .join(', ');
100
+ console.warn(
101
+ `${LOG_PREFIX} superseded extension(s) still installed: ${packages}. Their assistants are suppressed here.`
102
+ );
103
+ // Once, not on every refresh: this runs a single time per page load.
104
+ Notification.warning(
105
+ `AI Code Assistants: uninstall ${packages} - it is superseded by this extension, and until it goes its assistant has no panel here.`,
106
+ { autoClose: false }
107
+ );
108
+ }
109
+
110
+ let status: IStatusResponse | null = null;
111
+ let settings: ISettingRegistry.ISettings | null = null;
112
+ let sidebar: Sidebar = 'right';
113
+
114
+ /** Ask the server which providers exist and which of their binaries are on
115
+ * PATH. A failure leaves the extension activated with no panels - never a
116
+ * dialog, and never a thrown activation. */
117
+ const probeStatus = async (): Promise<void> => {
118
+ try {
119
+ status = await requestAPI<IStatusResponse>('status', serverSettings, {
120
+ cache: 'no-store'
121
+ });
122
+ } catch (err) {
123
+ console.warn(
124
+ `${LOG_PREFIX} status probe failed; no assistant panels will be docked.`,
125
+ err
126
+ );
127
+ status = null;
128
+ }
129
+ };
130
+
131
+ const statusFor = (id: string): IProviderStatus | null =>
132
+ status?.providers?.find(p => p.id === id) ?? null;
133
+
134
+ /** Settings value, or the fallback when settings are unavailable or the
135
+ * key is absent. An absent enable key reads as enabled - only an explicit
136
+ * `false` turns a provider off. */
137
+ const setting = <T>(key: string, fallback: T): T => {
138
+ if (!settings) {
139
+ return fallback;
140
+ }
141
+ const value = settings.get(key).composite;
142
+ if (value === undefined || value === null) {
143
+ return fallback;
144
+ }
145
+ return value as unknown as T;
146
+ };
147
+
148
+ /** Launch-mode values for one provider, keyed by the mode ids the
149
+ * assistant itself uses. */
150
+ const modesFor = (id: string): Record<string, boolean | string> => {
151
+ const descriptor = registry.get(id)?.descriptor;
152
+ const modes: Record<string, boolean | string> = {};
153
+ for (const mode of descriptor?.launchModes ?? []) {
154
+ modes[mode.id] = setting(`providers.${id}.${mode.id}`, mode.default);
155
+ }
156
+ return modes;
157
+ };
158
+
159
+ /** Push the shared settings into one panel. */
160
+ const applySharedSettings = (panel: AssistantSessionsPanel): void => {
161
+ // Any value other than `path` is treated as `name`, so an older saved
162
+ // value still maps onto a live mode.
163
+ const mode: PresentationMode =
164
+ setting<string>('presentationMode', 'name') === 'path'
165
+ ? 'path'
166
+ : 'name';
167
+ panel.setPresentationMode(mode);
168
+ const limit = setting<number>('recentLimit', 10);
169
+ if (typeof limit === 'number') {
170
+ panel.setRecentLimit(limit);
171
+ }
172
+ // Defaults ON, so only an explicit `false` turns it off.
173
+ panel.setColouredTabs(setting<boolean>('colouredTabs', true) !== false);
174
+ panel.setModes(modesFor(panel.descriptor.id));
175
+ };
176
+
177
+ const dock = (panel: AssistantSessionsPanel): void => {
178
+ // Lumino re-parents cleanly when add() is called against a different
179
+ // area, so the same call both docks and moves.
180
+ labShell.add(panel, sidebar, { rank: PANEL_RANK });
181
+ };
182
+
183
+ const start = (id: string): void => {
184
+ const module = registry.get(id);
185
+ if (!module || live.has(id)) {
186
+ return;
187
+ }
188
+ try {
189
+ const panel = new AssistantSessionsPanel({
190
+ app,
191
+ descriptor: module.descriptor,
192
+ hooks: module.hooks,
193
+ rootDir: status?.root_dir ?? '',
194
+ deleteToTrash: status?.delete_to_trash !== false,
195
+ terminalTracker,
196
+ fileBrowser,
197
+ colourfulTabs
198
+ });
199
+ applySharedSettings(panel);
200
+ dock(panel);
201
+ // Each panel registers under its own name, so its open/closed state
202
+ // survives a reload independently of the others.
203
+ restorer?.add(panel, panelWidgetId(id));
204
+ const command = app.commands.addCommand(commandId(id, 'refresh'), {
205
+ label: `Refresh ${module.descriptor.panelTitle}`,
206
+ execute: () => panel.refresh()
207
+ });
208
+ live.set(id, { panel, command });
209
+ } catch (err) {
210
+ // One provider failing to start must leave the others working.
211
+ console.error(`${LOG_PREFIX} provider "${id}" failed to start.`, err);
212
+ }
213
+ };
214
+
215
+ const stop = (id: string): void => {
216
+ const entry = live.get(id);
217
+ if (!entry) {
218
+ return;
219
+ }
220
+ live.delete(id);
221
+ // Disposing stops the polling and clears this provider's tab tints; the
222
+ // terminals it launched keep running, untouched.
223
+ entry.command.dispose();
224
+ entry.panel.dispose();
225
+ };
226
+
227
+ /** Bring the docked panels in line with settings and CLI availability.
228
+ * Both must hold for a panel to be present. */
229
+ const reconcile = (): void => {
230
+ for (const descriptor of registry.descriptors) {
231
+ const id = descriptor.id;
232
+ if (suppressed.has(id)) {
233
+ continue;
234
+ }
235
+ const enabled =
236
+ setting<boolean>(`providers.${id}.enabled`, true) !== false;
237
+ const probe = statusFor(id);
238
+ const available = probe ? probe.available !== false : false;
239
+ // Enabled but binary absent must not be a silent state: the user sees
240
+ // a missing panel with no clue that PATH under the Jupyter server
241
+ // differs from their shell. Once per id, not per reconcile.
242
+ if (enabled && !available && !warnedUnavailable.has(id)) {
243
+ warnedUnavailable.add(id);
244
+ const binary = registry.get(id)?.descriptor.cliBinary ?? id;
245
+ console.info(
246
+ `${LOG_PREFIX} "${id}" is enabled but its \`${binary}\` binary was not found on the server's PATH; panel not shown.`
247
+ );
248
+ }
249
+ if (enabled && available) {
250
+ if (live.has(id)) {
251
+ applySharedSettings(live.get(id)!.panel);
252
+ } else {
253
+ start(id);
254
+ }
255
+ } else {
256
+ stop(id);
257
+ }
258
+ }
259
+ // A provider still named in saved settings but no longer registered is
260
+ // inert. Warned once per id, not once per reconcile - this runs on every
261
+ // status poll, and a warning that repeats forever is noise.
262
+ for (const key of settings ? Object.keys(settings.composite) : []) {
263
+ const match = /^providers\.([^.]+)\.enabled$/.exec(key);
264
+ if (match && !registry.has(match[1]) && !warnedUnknown.has(match[1])) {
265
+ warnedUnknown.add(match[1]);
266
+ console.warn(
267
+ `${LOG_PREFIX} settings name unknown assistant "${match[1]}" - ignored.`
268
+ );
269
+ }
270
+ }
271
+ };
272
+
273
+ /** Carry favourites and settings over from the retired standalone
274
+ * extensions, once, before the first panel is docked.
275
+ *
276
+ * The server writes the favourites itself and hands back the mapped
277
+ * settings keys, because it has no writable handle on the settings
278
+ * registry. It is idempotent - a provider is marked migrated and skipped
279
+ * forever after - so calling it on every activation costs one request and
280
+ * migrates nothing the second time. A failure is a warning and nothing
281
+ * more: losing a carried-over favourite must never cost the user a panel.
282
+ *
283
+ * Skipped entirely while the settings registry is unavailable: the server
284
+ * marks a provider migrated in the same call that HANDS BACK its settings
285
+ * keys, so migrating with nowhere to apply them would drop a carried-over
286
+ * value permanently - the marker blocks every retry.
287
+ */
288
+ const migrate = async (): Promise<void> => {
289
+ if (!settings) {
290
+ return;
291
+ }
292
+ try {
293
+ const result = await requestAPI<IMigrateResponse>(
294
+ 'migrate',
295
+ serverSettings,
296
+ { method: 'POST', body: '{}' }
297
+ );
298
+ for (const entry of result.migrated ?? []) {
299
+ for (const [key, value] of Object.entries(entry.keys ?? {})) {
300
+ await settings?.set(key, value as any);
301
+ }
302
+ }
303
+ } catch (err) {
304
+ console.warn(
305
+ `${LOG_PREFIX} migration from the retired extensions failed; nothing was carried over.`,
306
+ err
307
+ );
308
+ }
309
+ };
310
+
311
+ const applySidebar = (): void => {
312
+ const next = setting<string>('sidebar', 'right');
313
+ if ((next === 'left' || next === 'right') && next !== sidebar) {
314
+ sidebar = next;
315
+ live.forEach(entry => dock(entry.panel));
316
+ }
317
+ };
318
+
319
+ await probeStatus();
320
+
321
+ if (settingRegistry) {
322
+ try {
323
+ settings = await settingRegistry.load(PLUGIN_ID);
324
+ sidebar =
325
+ setting<string>('sidebar', 'right') === 'left' ? 'left' : 'right';
326
+ settings.changed.connect(() => {
327
+ applySidebar();
328
+ reconcile();
329
+ });
330
+ } catch (err) {
331
+ // Defaults apply: every provider enabled, right sidebar.
332
+ console.warn(
333
+ `${LOG_PREFIX} failed to load settings; using defaults.`,
334
+ err
335
+ );
336
+ settings = null;
337
+ }
338
+ }
339
+
340
+ await migrate();
341
+
342
+ reconcile();
343
+
344
+ // Re-probe on a slow cadence so a binary installed (or removed) while
345
+ // JupyterLab runs takes effect on the next refresh, without a reload.
346
+ window.setInterval(() => {
347
+ void probeStatus().then(reconcile);
348
+ }, STATUS_INTERVAL_MS);
349
+
350
+ console.log(
351
+ `${LOG_PREFIX} ${live.size} of ${registry.ids.length} assistant panel(s) docked.`
352
+ );
353
+ }
354
+ };
355
+
356
+ export default plugin;