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,446 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { Dialog } from '@jupyterlab/apputils';
3
+ import { ServerConnection } from '@jupyterlab/services';
4
+ import { ITerminalTracker } from '@jupyterlab/terminal';
5
+ import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
6
+ import { Widget } from '@lumino/widgets';
7
+
8
+ import {
9
+ ColourStore,
10
+ effectiveColour,
11
+ readUserSetTabColour,
12
+ sessionForCwds
13
+ } from './colour';
14
+ import { requestProvider } from './request';
15
+ import {
16
+ ILaunchRequest,
17
+ ILaunchResponse,
18
+ IProviderDescriptor,
19
+ ISession,
20
+ ITerminalProbeResponse
21
+ } from './types';
22
+
23
+ /** Colour reconciliation cadence. Matches the row poll so the two collapse
24
+ * into one rhythm while the panel is visible, but it is a separate timer: a
25
+ * terminal running the assistant is tinted whether or not the panel is open. */
26
+ const COLOUR_INTERVAL_MS = 30_000;
27
+
28
+ /** Show a modal spinner while a terminal is being launched. The caller
29
+ * dismisses it with `.dispose()` when the launch settles.
30
+ *
31
+ * Cancel does not abort the request - it only takes the app-wide scrim away, so
32
+ * a stalled launch cannot leave the user staring at a modal with no exit. If
33
+ * the launch later succeeds the terminal simply appears. */
34
+ export function showLaunchSpinner(title: string): Dialog<unknown> {
35
+ const body = new Widget();
36
+ body.node.className = 'jp-AiAssistantsPanel-launchOverlay';
37
+
38
+ const spinner = document.createElement('div');
39
+ spinner.className =
40
+ 'jp-aiAssistants-spinner jp-AiAssistantsPanel-launchSpinner';
41
+ body.node.appendChild(spinner);
42
+
43
+ const dialog = new Dialog<unknown>({
44
+ title,
45
+ body,
46
+ buttons: [Dialog.cancelButton()]
47
+ });
48
+ // `launch()` returns a promise nobody awaits - disposing an open Lumino
49
+ // dialog rejects it with `undefined`, so swallow that here and keep a benign
50
+ // teardown from surfacing as an unhandled rejection.
51
+ dialog.launch().catch(() => undefined);
52
+ return dialog;
53
+ }
54
+
55
+ /**
56
+ * Terminals for one provider: the reuse ladder, the launch call and the tab
57
+ * tint pass.
58
+ *
59
+ * Split out of the panel because none of it is presentation - it is process
60
+ * identity. The panel asks for a conversation and gets a focused terminal
61
+ * running exactly that conversation, or a fresh one; nothing in here knows
62
+ * about sections, filters or menus.
63
+ */
64
+ export class TerminalManager {
65
+ constructor(options: TerminalManager.IOptions) {
66
+ this._app = options.app;
67
+ this._descriptor = options.descriptor;
68
+ this._colours = options.colourStore;
69
+ this._tracker = options.terminalTracker;
70
+ this._colourfulTabs = options.colourfulTabs;
71
+ this._serverSettings = options.serverSettings;
72
+ }
73
+
74
+ dispose(): void {
75
+ this._disposed = true;
76
+ this.stopColourLoop();
77
+ }
78
+
79
+ /**
80
+ * Open a conversation, reusing an open terminal only when that terminal is
81
+ * POSITIVELY running it.
82
+ *
83
+ * Three steps, cheapest first: the per-project microcache, then a walk of
84
+ * every terminal JupyterLab knows about (each probed server-side for the
85
+ * conversation its process actually holds), then a fresh launch. A terminal
86
+ * whose conversation cannot be read is never reused - it may be running a
87
+ * different conversation of the same project, and a session id is globally
88
+ * unique, so an unreadable terminal can never be proven to be the wanted one.
89
+ *
90
+ * Concurrent clicks on the SAME conversation attach to the in-flight
91
+ * promise; a different conversation of the same project launches
92
+ * independently, so branches open side by side.
93
+ */
94
+ async openSession(
95
+ session: ISession,
96
+ mode: string | undefined,
97
+ sessionId?: string
98
+ ): Promise<void> {
99
+ const wanted = sessionId ?? session.session_id;
100
+ const key = this._key(session.project_path, wanted);
101
+ const inFlight = this._pending.get(key);
102
+ if (inFlight) {
103
+ return inFlight;
104
+ }
105
+ const promise = this._doOpenSession(session, mode, wanted).finally(() => {
106
+ this._pending.delete(key);
107
+ });
108
+ this._pending.set(key, promise);
109
+ return promise;
110
+ }
111
+
112
+ private async _doOpenSession(
113
+ session: ISession,
114
+ mode: string | undefined,
115
+ wanted: string
116
+ ): Promise<void> {
117
+ // 1. Microcache - the most recent terminal of this project, reused only
118
+ // when it carries the wanted conversation.
119
+ const cached = this._byProject.get(session.project_path);
120
+ if (cached && !cached.widget.isDisposed && cached.sessionId === wanted) {
121
+ this.focus(cached.widget);
122
+ return;
123
+ }
124
+
125
+ // 2. Walk every live terminal, asking the server what each is running.
126
+ const found = await this.findForSession(wanted);
127
+ if (found) {
128
+ // Tag with the OBSERVED conversation, so a later reuse trusts a
129
+ // confirmed identity rather than a wish.
130
+ this._byProject.set(session.project_path, {
131
+ widget: found.widget,
132
+ sessionId: found.runningId ?? undefined
133
+ });
134
+ this._wireDisposal(session.project_path, found.widget);
135
+ this.focus(found.widget);
136
+ return;
137
+ }
138
+
139
+ // 3. Fresh launch. The server picks the verb (resume, attach, bare) at
140
+ // launch time, because ownership of a conversation can change inside the
141
+ // poll window and only the server can be authoritative about it.
142
+ await this.launch(
143
+ {
144
+ project_path: session.project_path,
145
+ encoded_path: session.encoded_path,
146
+ session_id: wanted,
147
+ mode
148
+ },
149
+ wanted
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Launch a terminal for one request and focus it. The returned widget is
155
+ * tagged with `tagSessionId` in the microcache, so a later click on the row
156
+ * that conversation now belongs to reuses this very terminal.
157
+ */
158
+ async launch(request: ILaunchRequest, tagSessionId?: string): Promise<any> {
159
+ const spinner = showLaunchSpinner(`Opening ${this._descriptor.label}`);
160
+ try {
161
+ const launched = await requestProvider<ILaunchResponse>(
162
+ this._descriptor.id,
163
+ 'launch',
164
+ this._serverSettings,
165
+ { method: 'POST', body: JSON.stringify(request) }
166
+ );
167
+ const widget: any = await this._app.commands.execute('terminal:open', {
168
+ name: launched.terminal_name
169
+ });
170
+ if (widget?.id) {
171
+ this._byProject.set(request.project_path, {
172
+ widget,
173
+ sessionId: tagSessionId
174
+ });
175
+ this._wireDisposal(request.project_path, widget);
176
+ this.focus(widget);
177
+ }
178
+ return widget ?? null;
179
+ } finally {
180
+ spinner.dispose();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Bring a terminal tab to the front AND hand it keyboard focus, so typing
186
+ * starts without a second click. `activateById` only reveals the tab; the
187
+ * xterm inside does not reliably take DOM focus when the click came from the
188
+ * sidebar, so the focus call is deferred to the next frame - by then the
189
+ * widget is attached and visible.
190
+ */
191
+ focus(widget: any): void {
192
+ if (!widget || widget.isDisposed) {
193
+ return;
194
+ }
195
+ this._app.shell.activateById(widget.id);
196
+ requestAnimationFrame(() => {
197
+ try {
198
+ widget.content?.term?.focus?.();
199
+ } catch (_err) {
200
+ // Disposed in the meantime - nothing to focus.
201
+ }
202
+ });
203
+ }
204
+
205
+ /** Find an open terminal running exactly this conversation, or null.
206
+ *
207
+ * Matches on the conversation id ALONE. The server reads the true id from
208
+ * the running process, so a session the user started by hand is recognised
209
+ * too, not only launches that carry an argv id. An id is globally unique to
210
+ * one conversation, so a terminal holding it IS the one to focus, whatever
211
+ * cwd it reports - a process that changed directory, or whose project dir
212
+ * was recreated, must still be reused rather than duplicated. */
213
+ async findForSession(
214
+ wantedSessionId: string | undefined
215
+ ): Promise<{ widget: any; runningId: string | null } | null> {
216
+ if (!this._tracker || !wantedSessionId) {
217
+ return null;
218
+ }
219
+ for (const widget of this._liveTerminals()) {
220
+ const info = await this.probe(widget);
221
+ if (info && info.session_id && info.session_id === wantedSessionId) {
222
+ return { widget, runningId: info.session_id };
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /** Ask the server what a terminal is running. Null when the widget has no
229
+ * session name yet, or the probe fails - a terminal can vanish between
230
+ * enumeration and probe, which is a retry, not an error. */
231
+ async probe(widget: any): Promise<ITerminalProbeResponse | null> {
232
+ const name: string | undefined = widget?.content?.session?.name;
233
+ if (typeof name !== 'string' || !name) {
234
+ return null;
235
+ }
236
+ try {
237
+ return await requestProvider<ITerminalProbeResponse>(
238
+ this._descriptor.id,
239
+ `terminal/${encodeURIComponent(name)}`,
240
+ this._serverSettings
241
+ );
242
+ } catch (_err) {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ // --------------------------------------------------------------- colours
248
+
249
+ /** Turn tinting on or off. Off drops the tint from this provider's terminals
250
+ * at once rather than at the next reload; on re-tints from current state. */
251
+ setColouredTabs(on: boolean): void {
252
+ const enabled = !!on;
253
+ if (this._colouredTabs === enabled) {
254
+ return;
255
+ }
256
+ this._colouredTabs = enabled;
257
+ void (enabled ? this.reconcileColours() : this.clearColours());
258
+ }
259
+
260
+ /** Remember the sessions the last poll returned, so the colour loop can run
261
+ * on its own timer without asking the panel for state. */
262
+ setSessions(sessions: ISession[]): void {
263
+ this._sessions = sessions;
264
+ }
265
+
266
+ /** Arm the colour loop. Independent of panel visibility: a terminal running
267
+ * the assistant is tinted whether or not the panel is on screen. */
268
+ startColourLoop(): void {
269
+ if (this._disposed || this._colourHandle !== null) {
270
+ return;
271
+ }
272
+ this._colourHandle = window.setTimeout(() => {
273
+ this._colourHandle = null;
274
+ void this.reconcileColours();
275
+ }, COLOUR_INTERVAL_MS);
276
+ }
277
+
278
+ stopColourLoop(): void {
279
+ if (this._colourHandle !== null) {
280
+ window.clearTimeout(this._colourHandle);
281
+ this._colourHandle = null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Re-tint every open terminal of this assistant from the freshest colours,
287
+ * whether this panel launched it or the user opened it themselves.
288
+ *
289
+ * Each terminal's conversation is re-resolved on every pass rather than
290
+ * taken from the launch cache, which pins the launch-time conversation and
291
+ * goes stale on an in-place switch. On the way through, a colour the user
292
+ * set by hand on the tab is written back into this provider's colour store,
293
+ * which is what makes "set the tab colour" mean "this conversation's colour"
294
+ * even for an assistant whose CLI has no colour concept.
295
+ */
296
+ async reconcileColours(): Promise<void> {
297
+ if (!this._colouredTabs) {
298
+ // No rearm: `setColouredTabs(true)` calls straight back in here.
299
+ return;
300
+ }
301
+ try {
302
+ await this._eachAssistantTerminal(async (widget, info) => {
303
+ const sessionId =
304
+ info.session_id ??
305
+ sessionForCwds(info.cwds ?? [], this._sessions)?.session_id ??
306
+ null;
307
+ const terminalName: string = widget?.content?.session?.name ?? '';
308
+
309
+ // Write-back. Native-colour assistants own their colour, so their
310
+ // tabs are read-only here; for the others the tab IS the control
311
+ // surface.
312
+ if (
313
+ sessionId &&
314
+ terminalName &&
315
+ this._descriptor.colourSource !== 'native'
316
+ ) {
317
+ const userSet = readUserSetTabColour(terminalName);
318
+ if (userSet && userSet !== this._colours.get(sessionId)) {
319
+ await this._colours.set(sessionId, userSet);
320
+ }
321
+ }
322
+
323
+ const native =
324
+ info.colour ??
325
+ (sessionId
326
+ ? (this._sessions.find(s => s.session_id === sessionId)?.colour ??
327
+ null)
328
+ : null);
329
+ this._applyColour(
330
+ widget,
331
+ effectiveColour(
332
+ sessionId,
333
+ this._descriptor.colourSource,
334
+ this._colours.get(sessionId),
335
+ native
336
+ )
337
+ );
338
+ });
339
+ } finally {
340
+ // Always rearm, even after a failed pass - one bad probe must not end
341
+ // tinting for the rest of the session.
342
+ this.startColourLoop();
343
+ }
344
+ }
345
+
346
+ /** Effective colour of one conversation right now, for branch inheritance
347
+ * at the fork sites. */
348
+ colourFor(session: ISession, sessionId?: string): string | null {
349
+ const id = sessionId ?? session.session_id;
350
+ return effectiveColour(
351
+ id,
352
+ this._descriptor.colourSource,
353
+ this._colours.get(id),
354
+ session.colour ?? null
355
+ );
356
+ }
357
+
358
+ /** Drop the tint from this assistant's terminals. Only its own terminals are
359
+ * touched, so a tint the user set by hand on another tab survives. */
360
+ async clearColours(): Promise<void> {
361
+ await this._eachAssistantTerminal(async widget => {
362
+ this._applyColour(widget, null);
363
+ });
364
+ }
365
+
366
+ private _applyColour(widget: any, colourId: string | null): void {
367
+ if (!this._colourfulTabs || !widget || widget.isDisposed) {
368
+ return;
369
+ }
370
+ this._colourfulTabs.setColour(widget, colourId);
371
+ }
372
+
373
+ private async _eachAssistantTerminal(
374
+ apply: (widget: any, info: ITerminalProbeResponse) => void | Promise<void>
375
+ ): Promise<void> {
376
+ if (!this._colourfulTabs || !this._tracker) {
377
+ return;
378
+ }
379
+ const widgets = this._liveTerminals();
380
+ await Promise.all(
381
+ widgets.map(async widget => {
382
+ const info = await this.probe(widget);
383
+ if (!info || !info.running || widget.isDisposed) {
384
+ return;
385
+ }
386
+ await apply(widget, info);
387
+ })
388
+ );
389
+ }
390
+
391
+ private _liveTerminals(): any[] {
392
+ const widgets: any[] = [];
393
+ this._tracker?.forEach((widget: any) => {
394
+ if (widget && !widget.isDisposed) {
395
+ widgets.push(widget);
396
+ }
397
+ });
398
+ return widgets;
399
+ }
400
+
401
+ private _wireDisposal(projectPath: string, widget: any): void {
402
+ if (!widget?.disposed?.connect) {
403
+ return;
404
+ }
405
+ widget.disposed.connect(() => {
406
+ if (this._byProject.get(projectPath)?.widget === widget) {
407
+ this._byProject.delete(projectPath);
408
+ }
409
+ });
410
+ }
411
+
412
+ private _key(projectPath: string, sessionId?: string): string {
413
+ return `${projectPath}\n${sessionId ?? ''}`;
414
+ }
415
+
416
+ private readonly _app: JupyterFrontEnd;
417
+ private readonly _descriptor: IProviderDescriptor;
418
+ private readonly _colours: ColourStore;
419
+ private readonly _tracker: ITerminalTracker | null;
420
+ private readonly _colourfulTabs: IColourfulTabs | null;
421
+ private readonly _serverSettings: ServerConnection.ISettings;
422
+ // Most recent terminal per project, tagged with the conversation it runs so
423
+ // reuse can tell a project's branches apart.
424
+ private readonly _byProject: Map<
425
+ string,
426
+ { widget: any; sessionId?: string }
427
+ > = new Map();
428
+ // In-flight launches keyed per CONVERSATION, so two branches of one project
429
+ // open independently and concurrently.
430
+ private readonly _pending: Map<string, Promise<void>> = new Map();
431
+ private _sessions: ISession[] = [];
432
+ private _colouredTabs = true;
433
+ private _colourHandle: number | null = null;
434
+ private _disposed = false;
435
+ }
436
+
437
+ export namespace TerminalManager {
438
+ export interface IOptions {
439
+ app: JupyterFrontEnd;
440
+ descriptor: IProviderDescriptor;
441
+ colourStore: ColourStore;
442
+ terminalTracker: ITerminalTracker | null;
443
+ colourfulTabs: IColourfulTabs | null;
444
+ serverSettings: ServerConnection.ISettings;
445
+ }
446
+ }