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,475 @@
1
+ import { Clipboard, Dialog } from '@jupyterlab/apputils';
2
+ import { copyIcon } from '@jupyterlab/ui-components';
3
+ import { Widget } from '@lumino/widgets';
4
+
5
+ import { branchIcon } from './icons';
6
+ import { IBranch } from './types';
7
+
8
+ /**
9
+ * The Manage Sessions popup: a searchable, scrollable table over every
10
+ * conversation of one project, with the current one pinned at the top,
11
+ * checkbox multi-select and a two-step delete.
12
+ *
13
+ * Its own module because it is a screen, not a panel detail - the panel hands
14
+ * it a branch list and four callbacks and never sees its DOM.
15
+ */
16
+ export namespace ManageSessionsPopup {
17
+ export interface IOptions {
18
+ /** Conversations other than the current one. */
19
+ branches: IBranch[];
20
+ /** The project's current conversation id, pinned at the top. */
21
+ current: string;
22
+ /** Display name of the row this popup was opened from. */
23
+ projectName: string;
24
+ /** Whether deletions move to trash - the popup says which it means. */
25
+ deleteToTrash: boolean;
26
+ /** Display name for one branch, `<label> (<short id>)` by default. */
27
+ branchName: (branch: IBranch) => string;
28
+ /** Relative-time label for a branch. */
29
+ formatTime: (epochMs: number) => string;
30
+ /** Provider chip for a branch row, or null. */
31
+ branchBadge?: (branch: IBranch) => HTMLElement | null;
32
+ /** Make a conversation the row's current one. */
33
+ onSwitch: (sessionId: string) => void;
34
+ /** Open a conversation in its own terminal. */
35
+ onOpen: (sessionId: string) => void;
36
+ /** Delete conversations; resolves with the removed count, or null on a
37
+ * failure the caller has already reported. */
38
+ onDelete: (sessionIds: string[]) => Promise<number | null>;
39
+ /** Re-read the branch list from the server. */
40
+ onRefetch: () => Promise<IBranch[]>;
41
+ /** Called with the fresh list whenever the popup re-syncs, so the panel's
42
+ * own cached branch list does not drift from what is on screen. */
43
+ onSync?: (branches: IBranch[]) => void;
44
+ }
45
+ }
46
+
47
+ /** Compact copy button for a popup row. `stopPropagation` keeps the click from
48
+ * switching or selecting the row it sits in. */
49
+ function copyButton(sessionId: string): HTMLButtonElement {
50
+ const btn = document.createElement('button');
51
+ btn.type = 'button';
52
+ btn.className = 'jp-AiAssistantsPanel-branchCopy';
53
+ btn.title = 'Copy session id';
54
+ copyIcon.element({ container: btn });
55
+ btn.addEventListener('click', e => {
56
+ e.stopPropagation();
57
+ Clipboard.copyToSystem(sessionId);
58
+ });
59
+ return btn;
60
+ }
61
+
62
+ export function showManageSessionsPopup(
63
+ options: ManageSessionsPopup.IOptions
64
+ ): void {
65
+ // Local working copy so deletions can refresh the list in place.
66
+ let items = [...options.branches];
67
+ const selected = new Set<string>();
68
+ let deleting = false; // guards the async delete against double invocation
69
+ // Permanent deletion arms first and executes on the second click; a trashed
70
+ // deletion is recoverable and goes straight through.
71
+ const armsFirst = !options.deleteToTrash;
72
+ let armed = false;
73
+ let armTimer = 0;
74
+
75
+ const body = document.createElement('div');
76
+ body.className = 'jp-AiAssistantsPanel-branchPopup';
77
+
78
+ const search = document.createElement('input');
79
+ search.type = 'search';
80
+ search.placeholder = 'Filter sessions...';
81
+ search.setAttribute('aria-label', 'Filter sessions');
82
+ search.className = 'jp-AiAssistantsPanel-branchSearch';
83
+ body.appendChild(search);
84
+
85
+ // Header strip: select-all left, conversation count right.
86
+ const header = document.createElement('div');
87
+ header.className = 'jp-AiAssistantsPanel-branchHeader';
88
+ const selectAllBar = document.createElement('label');
89
+ selectAllBar.className = 'jp-AiAssistantsPanel-branchSelectAll';
90
+ const selectAll = document.createElement('input');
91
+ selectAll.type = 'checkbox';
92
+ selectAllBar.appendChild(selectAll);
93
+ selectAllBar.appendChild(document.createTextNode('Select all'));
94
+ header.appendChild(selectAllBar);
95
+ const countEl = document.createElement('span');
96
+ countEl.className = 'jp-AiAssistantsPanel-branchHeaderCount';
97
+ header.appendChild(countEl);
98
+ body.appendChild(header);
99
+
100
+ const list = document.createElement('div');
101
+ list.className = 'jp-AiAssistantsPanel-branchList';
102
+ // role=group makes the aria-label apply to the conversation list region.
103
+ list.setAttribute('role', 'group');
104
+ list.setAttribute('aria-label', 'Conversations');
105
+ body.appendChild(list);
106
+
107
+ const footer = document.createElement('div');
108
+ footer.className = 'jp-AiAssistantsPanel-branchFooter';
109
+ // Plain visual counter (selection, or the last action). The screen-reader
110
+ // announcement lives in its own live region so per-checkbox ticks are not
111
+ // announced on top of the native checkbox.
112
+ const selCount = document.createElement('span');
113
+ selCount.className = 'jp-AiAssistantsPanel-branchSelCount';
114
+ footer.appendChild(selCount);
115
+ const deleteBtn = document.createElement('button');
116
+ deleteBtn.type = 'button';
117
+ deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
118
+ deleteBtn.title = options.deleteToTrash
119
+ ? 'Deleted conversations move to the trash (recoverable)'
120
+ : 'Deleted conversations are removed permanently';
121
+ footer.appendChild(deleteBtn);
122
+ body.appendChild(footer);
123
+
124
+ // Visually hidden polite live region, announced only on delete.
125
+ const srLive = document.createElement('div');
126
+ srLive.className = 'jp-AiAssistantsPanel-srOnly';
127
+ srLive.setAttribute('role', 'status');
128
+ srLive.setAttribute('aria-live', 'polite');
129
+ body.appendChild(srLive);
130
+
131
+ const bodyWidget = new Widget({ node: body });
132
+ const dialog = new Dialog({
133
+ title: 'Manage Sessions',
134
+ body: bodyWidget,
135
+ buttons: [Dialog.cancelButton()]
136
+ });
137
+
138
+ // Per-row "Open" launches that conversation in its own terminal and closes
139
+ // the popup. stopPropagation keeps the click from toggling selection or
140
+ // switching the row.
141
+ const openButton = (sessionId: string): HTMLButtonElement => {
142
+ const btn = document.createElement('button');
143
+ btn.type = 'button';
144
+ btn.className = 'jp-AiAssistantsPanel-branchOpen';
145
+ btn.textContent = 'Open';
146
+ btn.title = 'Open this conversation in its own terminal';
147
+ btn.addEventListener('click', e => {
148
+ e.stopPropagation();
149
+ dialog.dispose();
150
+ options.onOpen(sessionId);
151
+ });
152
+ return btn;
153
+ };
154
+
155
+ const visibleMatches = (): IBranch[] => {
156
+ const needle = search.value.trim().toLowerCase();
157
+ return items.filter(
158
+ b =>
159
+ !needle ||
160
+ b.label.toLowerCase().includes(needle) ||
161
+ b.session_id.toLowerCase().includes(needle)
162
+ );
163
+ };
164
+
165
+ /** Drop the armed state - the count an armed button names must never outlive
166
+ * the selection it was armed for. Callers refresh the button themselves. */
167
+ const disarm = (): void => {
168
+ armed = false;
169
+ window.clearTimeout(armTimer);
170
+ };
171
+
172
+ const updateControls = (): void => {
173
+ deleteBtn.disabled = deleting || selected.size === 0;
174
+ deleteBtn.textContent = deleting
175
+ ? 'Deleting...'
176
+ : armed
177
+ ? `Confirm delete ${selected.size}`
178
+ : `Delete (${selected.size})`;
179
+ deleteBtn.classList.toggle('jp-mod-armed', armed && !deleting);
180
+ selCount.textContent = selected.size ? `${selected.size} selected` : '';
181
+ selCount.classList.remove('jp-mod-deleted');
182
+ const visible = visibleMatches();
183
+ const total = items.length + 1; // + the pinned current conversation
184
+ const shown = visible.length + 1; // the current row is always shown
185
+ countEl.textContent = search.value.trim()
186
+ ? `${shown} of ${total}`
187
+ : `${total} conversation${total === 1 ? '' : 's'}`;
188
+ const visibleSelected = visible.filter(b =>
189
+ selected.has(b.session_id)
190
+ ).length;
191
+ selectAll.checked =
192
+ visible.length > 0 && visibleSelected === visible.length;
193
+ selectAll.indeterminate =
194
+ visibleSelected > 0 && visibleSelected < visible.length;
195
+ };
196
+
197
+ const render = (): void => {
198
+ list.replaceChildren();
199
+
200
+ // The current conversation leads the list - badged, unselectable,
201
+ // undeletable; only the extras below it are manageable.
202
+ const currentRow = document.createElement('div');
203
+ currentRow.className = 'jp-AiAssistantsPanel-branchRow jp-mod-current';
204
+ currentRow.title = `Session id: ${options.current}`;
205
+ // The brand left-bar, tint and plain "current" text are visual-only cues.
206
+ currentRow.setAttribute('aria-current', 'true');
207
+ // Empty select cell keeps the name column aligned with branch rows.
208
+ const currentSelect = document.createElement('span');
209
+ currentSelect.className = 'jp-AiAssistantsPanel-branchSelectCell';
210
+ currentRow.appendChild(currentSelect);
211
+ const currentLabel = document.createElement('span');
212
+ currentLabel.className = 'jp-AiAssistantsPanel-branchLabel';
213
+ // Text in its own ellipsising span so any chip survives truncation - the
214
+ // same structure the branch rows below use.
215
+ const currentText = document.createElement('span');
216
+ currentText.className = 'jp-AiAssistantsPanel-branchLabelText';
217
+ currentText.textContent = `${options.projectName} (${options.current.slice(
218
+ 0,
219
+ 8
220
+ )})`;
221
+ currentLabel.appendChild(currentText);
222
+ currentRow.appendChild(currentLabel);
223
+ const badge = document.createElement('span');
224
+ badge.className = 'jp-AiAssistantsPanel-branchCurrentBadge';
225
+ badge.textContent = 'current';
226
+ currentRow.appendChild(badge);
227
+ currentRow.appendChild(openButton(options.current));
228
+ currentRow.appendChild(copyButton(options.current));
229
+ list.appendChild(currentRow);
230
+
231
+ const matches = visibleMatches();
232
+ if (matches.length === 0) {
233
+ const empty = document.createElement('div');
234
+ empty.className = 'jp-AiAssistantsPanel-emptySection';
235
+ empty.textContent = items.length
236
+ ? 'No matching sessions.'
237
+ : 'No other conversations.';
238
+ list.appendChild(empty);
239
+ return;
240
+ }
241
+
242
+ for (const b of matches) {
243
+ const row = document.createElement('div');
244
+ row.className = 'jp-AiAssistantsPanel-branchRow';
245
+ row.title = `Session id: ${b.session_id}`;
246
+
247
+ const selectCell = document.createElement('span');
248
+ selectCell.className = 'jp-AiAssistantsPanel-branchSelectCell';
249
+ const check = document.createElement('input');
250
+ check.type = 'checkbox';
251
+ check.checked = selected.has(b.session_id);
252
+ check.setAttribute('aria-label', `Select ${options.branchName(b)}`);
253
+ // The checkbox is its own click zone - ticking must not switch.
254
+ check.addEventListener('click', e => {
255
+ e.stopPropagation();
256
+ if (deleting) {
257
+ // Keyboard Space is not blocked by the busy scrim's pointer-events;
258
+ // re-sync the native toggle instead.
259
+ check.checked = selected.has(b.session_id);
260
+ return;
261
+ }
262
+ if (check.checked) {
263
+ selected.add(b.session_id);
264
+ } else {
265
+ selected.delete(b.session_id);
266
+ }
267
+ disarm();
268
+ updateControls();
269
+ });
270
+ selectCell.appendChild(check);
271
+ // The whole cell is a select target (>=24px), not just the checkbox.
272
+ selectCell.addEventListener('click', e => {
273
+ if (e.target !== check) {
274
+ e.stopPropagation();
275
+ check.click();
276
+ }
277
+ });
278
+ row.appendChild(selectCell);
279
+
280
+ const label = document.createElement('span');
281
+ label.className = 'jp-AiAssistantsPanel-branchLabel';
282
+ // The text gets its own ellipsising span so the marker after it can
283
+ // never be clipped away: branch labels are titles that routinely fill
284
+ // the label column, and a marker inside the ellipsised span vanishes on
285
+ // exactly those rows.
286
+ const labelText = document.createElement('span');
287
+ labelText.className = 'jp-AiAssistantsPanel-branchLabelText';
288
+ labelText.textContent = options.branchName(b);
289
+ label.appendChild(labelText);
290
+ const providerBadge = options.branchBadge?.(b) ?? null;
291
+ if (providerBadge) {
292
+ label.appendChild(providerBadge);
293
+ } else {
294
+ const fork = document.createElement('span');
295
+ fork.className = 'jp-AiAssistantsPanel-branchBadge';
296
+ const icon = document.createElement('span');
297
+ icon.className = 'jp-AiAssistantsPanel-branchBadgeIcon';
298
+ branchIcon.element({ container: icon });
299
+ fork.appendChild(icon);
300
+ label.appendChild(fork);
301
+ }
302
+ row.appendChild(label);
303
+
304
+ const time = document.createElement('span');
305
+ time.className = 'jp-AiAssistantsPanel-branchTime';
306
+ time.textContent = options.formatTime(b.file_mtime);
307
+ row.appendChild(time);
308
+
309
+ row.appendChild(openButton(b.session_id));
310
+ row.appendChild(copyButton(b.session_id));
311
+
312
+ const activate = (): void => {
313
+ // Selection mode: while anything is ticked, row clicks toggle
314
+ // selection - no accidental switch mid-selection.
315
+ if (selected.size > 0) {
316
+ if (selected.has(b.session_id)) {
317
+ selected.delete(b.session_id);
318
+ } else {
319
+ selected.add(b.session_id);
320
+ }
321
+ check.checked = selected.has(b.session_id);
322
+ disarm();
323
+ updateControls();
324
+ return;
325
+ }
326
+ dialog.dispose();
327
+ options.onSwitch(b.session_id);
328
+ };
329
+ row.addEventListener('click', activate);
330
+ // Switch has no button of its own, so it needs a role and a key route or
331
+ // it is reachable by mouse only. The affordance sits on the label span,
332
+ // not the row: the row also hosts a checkbox and the Open/Copy buttons,
333
+ // and a role="button" wrapping them both swallows their own Enter/Space
334
+ // and makes them presentational children (ARIA nested-interactive).
335
+ label.setAttribute('role', 'button');
336
+ label.tabIndex = 0;
337
+ label.addEventListener('keydown', e => {
338
+ // The busy scrim is pointer-events CSS and does not block keyboard.
339
+ if (deleting) {
340
+ return;
341
+ }
342
+ if (e.key === 'Enter' || e.key === ' ') {
343
+ // Space would scroll the list otherwise.
344
+ e.preventDefault();
345
+ activate();
346
+ }
347
+ });
348
+ list.appendChild(row);
349
+ }
350
+ };
351
+
352
+ selectAll.addEventListener('change', () => {
353
+ if (deleting) {
354
+ return;
355
+ }
356
+ // Select-all acts on the visible (filtered) rows only.
357
+ const visible = visibleMatches();
358
+ if (selectAll.checked) {
359
+ visible.forEach(b => selected.add(b.session_id));
360
+ } else {
361
+ visible.forEach(b => selected.delete(b.session_id));
362
+ }
363
+ disarm();
364
+ render();
365
+ updateControls();
366
+ });
367
+
368
+ // Busy-lock the whole body during the async delete, so a slow backend cannot
369
+ // be double-clicked into deleting the same set twice and a mid-flight
370
+ // selection cannot be silently discarded. The Dialog's Cancel button sits
371
+ // outside the body and stays usable.
372
+ const setDeleting = (on: boolean): void => {
373
+ deleting = on;
374
+ body.classList.toggle('jp-mod-busy', on);
375
+ if (on) {
376
+ list.setAttribute('aria-busy', 'true');
377
+ } else {
378
+ list.removeAttribute('aria-busy');
379
+ }
380
+ };
381
+
382
+ deleteBtn.addEventListener('click', () => {
383
+ if (deleting || selected.size === 0) {
384
+ return;
385
+ }
386
+ // No confirmation DIALOG here: a second Lumino dialog stacked on this popup
387
+ // renders detached. A permanent deletion still confirms, in the footer
388
+ // itself - the first click arms the button, the second executes, and any
389
+ // selection change or four seconds of hesitation disarms it. A trashed
390
+ // deletion is recoverable, so it keeps going through on one click, with
391
+ // the live "N deleted" status and a toast from the caller on failure.
392
+ if (armsFirst && !armed) {
393
+ armed = true;
394
+ window.clearTimeout(armTimer);
395
+ armTimer = window.setTimeout(() => {
396
+ disarm();
397
+ updateControls();
398
+ }, 4000);
399
+ updateControls();
400
+ return;
401
+ }
402
+ disarm();
403
+ setDeleting(true);
404
+ updateControls();
405
+ void options
406
+ .onDelete([...selected])
407
+ .then(async deleted => {
408
+ if (deleted === null) {
409
+ setDeleting(false);
410
+ updateControls(); // re-enable; selection unchanged
411
+ return;
412
+ }
413
+ // Re-sync from disk truth, not an optimistic splice: a conversation
414
+ // the server skipped (it became the resolved-current, or was already
415
+ // gone) must not vanish from the list while it still exists.
416
+ try {
417
+ items = await options.onRefetch();
418
+ } catch {
419
+ // Refetch failed (rare): optimistic removal. The announced count may
420
+ // then differ from the rows removed; the panel's own poll reconciles.
421
+ items = items.filter(b => !selected.has(b.session_id));
422
+ }
423
+ // The user may have closed the popup during the refetch await - do not
424
+ // touch shared state or a detached DOM in that case.
425
+ if (!bodyWidget.isAttached) {
426
+ return;
427
+ }
428
+ setDeleting(false);
429
+ selected.clear();
430
+ options.onSync?.(items);
431
+ render();
432
+ updateControls();
433
+ const moved = options.deleteToTrash
434
+ ? `${deleted} moved to trash`
435
+ : `${deleted} deleted`;
436
+ selCount.textContent = moved;
437
+ selCount.classList.add('jp-mod-deleted');
438
+ // Clear then re-set on the next tick so an identical count (two single
439
+ // deletes in a row) is still re-announced by aria-live.
440
+ srLive.textContent = '';
441
+ window.setTimeout(() => {
442
+ srLive.textContent = moved;
443
+ }, 60);
444
+ // Keep focus inside the dialog - render() destroyed the focused row.
445
+ // The select-all checkbox is a real control with a reliable keyboard
446
+ // ring, unless the user is still typing in the search box.
447
+ if (document.activeElement !== search) {
448
+ selectAll.focus();
449
+ }
450
+ })
451
+ .catch(() => {
452
+ // Defensive: never leave the button stuck disabled.
453
+ if (bodyWidget.isAttached) {
454
+ setDeleting(false);
455
+ updateControls();
456
+ }
457
+ });
458
+ });
459
+
460
+ search.addEventListener('input', () => {
461
+ if (deleting) {
462
+ return;
463
+ }
464
+ render();
465
+ updateControls();
466
+ });
467
+
468
+ render();
469
+ updateControls();
470
+
471
+ // dialog.dispose() (on open/switch) rejects this promise with `undefined`;
472
+ // catch it so the teardown never surfaces as an unhandled rejection.
473
+ dialog.launch().catch(() => undefined);
474
+ search.focus();
475
+ }
@@ -0,0 +1,60 @@
1
+ import { IProviderDescriptor, IProviderModule } from './types';
2
+
3
+ /** Console prefix for everything this extension logs. One spelling, so a
4
+ * reader filtering the console on it sees every message the extension wrote. */
5
+ export const LOG_PREFIX = '[jupyterlab_ai_code_assistants_extension]';
6
+
7
+ /**
8
+ * The frontend provider registry.
9
+ *
10
+ * Built once from the `providers/index.ts` barrel. Registration is the only
11
+ * place a provider id is ever asserted, so a duplicate fails loudly here
12
+ * rather than silently at render time - two panels sharing a widget id, a
13
+ * command namespace and a settings key is not a state worth recovering from.
14
+ */
15
+ export class ProviderRegistry {
16
+ constructor(modules: IProviderModule[]) {
17
+ for (const module of modules) {
18
+ const descriptor = module?.descriptor;
19
+ if (!descriptor?.id) {
20
+ console.warn(
21
+ `${LOG_PREFIX} ignoring a provider module with no descriptor id`
22
+ );
23
+ continue;
24
+ }
25
+ if (this._modules.has(descriptor.id)) {
26
+ throw new Error(
27
+ `Duplicate AI code assistant provider id "${descriptor.id}" - ` +
28
+ 'two descriptors cannot share an id.'
29
+ );
30
+ }
31
+ this._modules.set(descriptor.id, module);
32
+ }
33
+ }
34
+
35
+ /** Every registered module, in barrel order. */
36
+ get modules(): IProviderModule[] {
37
+ return [...this._modules.values()];
38
+ }
39
+
40
+ get descriptors(): IProviderDescriptor[] {
41
+ return this.modules.map(m => m.descriptor);
42
+ }
43
+
44
+ get ids(): string[] {
45
+ return [...this._modules.keys()];
46
+ }
47
+
48
+ has(id: string): boolean {
49
+ return this._modules.has(id);
50
+ }
51
+
52
+ /** The module for an id, or null. A stale id in saved settings is a warning
53
+ * and a null, never a hard failure. */
54
+ get(id: string): IProviderModule | null {
55
+ return this._modules.get(id) ?? null;
56
+ }
57
+
58
+ // Insertion-ordered, so `modules` and `ids` come back in barrel order.
59
+ private readonly _modules: Map<string, IProviderModule> = new Map();
60
+ }
@@ -0,0 +1,96 @@
1
+ import { URLExt } from '@jupyterlab/coreutils';
2
+
3
+ import { ServerConnection } from '@jupyterlab/services';
4
+
5
+ /** The server extension's API namespace. Every route lives under it. */
6
+ export const API_NAMESPACE = 'jupyterlab-ai-code-assistants-extension';
7
+
8
+ /**
9
+ * Call the server extension.
10
+ *
11
+ * @param endPoint API REST end point for the extension, relative to the
12
+ * extension namespace
13
+ * @param serverSettings The server settings to use for the request
14
+ * @param init Initial values for the request
15
+ * @returns The response body interpreted as JSON
16
+ */
17
+ export async function requestAPI<T>(
18
+ endPoint: string,
19
+ serverSettings: ServerConnection.ISettings,
20
+ init: RequestInit = {}
21
+ ): Promise<T> {
22
+ const requestUrl = URLExt.join(
23
+ serverSettings.baseUrl,
24
+ API_NAMESPACE,
25
+ endPoint
26
+ );
27
+
28
+ let response: Response;
29
+ try {
30
+ response = await ServerConnection.makeRequest(
31
+ requestUrl,
32
+ init,
33
+ serverSettings
34
+ );
35
+ } catch (error) {
36
+ throw new ServerConnection.NetworkError(error as any);
37
+ }
38
+
39
+ let data: any = await response.text();
40
+
41
+ if (data.length > 0) {
42
+ try {
43
+ data = JSON.parse(data);
44
+ } catch (error) {
45
+ console.log('Not a JSON response body.', response);
46
+ }
47
+ }
48
+
49
+ if (!response.ok) {
50
+ throw new ServerConnection.ResponseError(response, data.message || data);
51
+ }
52
+
53
+ return data;
54
+ }
55
+
56
+ /** Build a provider-scoped path: `providers/<id>/<path>`. The id is encoded so
57
+ * a malformed registration can never escape the namespace. */
58
+ export function providerPath(providerId: string, path: string): string {
59
+ return `providers/${encodeURIComponent(providerId)}/${path}`;
60
+ }
61
+
62
+ /** Call a provider-scoped route. Every session, launch, colour and disposal
63
+ * call goes through here, which is what keeps one provider's traffic out of
64
+ * another's namespace. */
65
+ export async function requestProvider<T>(
66
+ providerId: string,
67
+ path: string,
68
+ serverSettings: ServerConnection.ISettings,
69
+ init: RequestInit = {}
70
+ ): Promise<T> {
71
+ return requestAPI<T>(providerPath(providerId, path), serverSettings, init);
72
+ }
73
+
74
+ /** Append a query string to a route path, skipping empty values. */
75
+ export function withQuery(
76
+ path: string,
77
+ params: Record<string, string | undefined>
78
+ ): string {
79
+ const parts: string[] = [];
80
+ for (const [key, value] of Object.entries(params)) {
81
+ if (value === undefined || value === '') {
82
+ continue;
83
+ }
84
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
85
+ }
86
+ return parts.length ? `${path}?${parts.join('&')}` : path;
87
+ }
88
+
89
+ /** True when the error is a server response carrying this status code - used
90
+ * to tell "the conversation is gone" apart from "the server is unreachable". */
91
+ export function isResponseStatus(err: unknown, status: number): boolean {
92
+ return (
93
+ err instanceof ServerConnection.ResponseError &&
94
+ err.response.status === status
95
+ );
96
+ }