jupyterlab_ai_code_assistants_extension 1.2.45 → 1.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
47
47
  - **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
48
48
  - **DeepSeek in the browser** - the DeepSeek Harness has no terminal chat; its panel lists the projects under `~/.dsh/sessions` (or `$DSH_HOME`) and `Open Web UI` starts `dsh --profile web --no-open --port 0` in the project's terminal, which prints the URL to open. The conversation is then chosen in the harness's own browser UI; branching and deleting work from the panel as for every other assistant. The URL is on the machine running JupyterLab, so a remote server needs a port forward to reach it
49
49
  - **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` does not show a panel
50
+ - **Open Callback** - when an assistant's login sends your browser to an `http://127.0.0.1:...` link that fails to load, because the login is waiting on the machine running JupyterLab, run `Open Callback` from the command palette and paste the link; the Jupyter server requests it for you, and the popup says whether the login accepted it. Only `http` links to that machine's own loopback are accepted
50
51
 
51
52
  ## Requirements
52
53
 
@@ -0,0 +1,15 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { ICommandPalette } from '@jupyterlab/apputils';
3
+ import { TranslationBundle } from '@jupyterlab/translation';
4
+ /** The command that delivers a pasted login callback link. */
5
+ export declare const CALLBACK_COMMAND = "open:callback";
6
+ /**
7
+ * Register the command that sends a pasted login callback link to the login
8
+ * waiting on the Jupyter server.
9
+ *
10
+ * A CLI login waits for its OAuth redirect on the server's own loopback, but
11
+ * the browser that follows the redirect runs on the user's machine, where that
12
+ * address is another host - so the page fails to load, with the link left in
13
+ * the address bar. Pasted here, the server makes the request instead.
14
+ */
15
+ export declare function addCallbackCommand(app: JupyterFrontEnd, palette: ICommandPalette | null, category: string, trans: TranslationBundle): void;
@@ -0,0 +1,140 @@
1
+ import { Dialog, Notification } from '@jupyterlab/apputils';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { requestAPI } from './request';
4
+ /** The command that delivers a pasted login callback link. */
5
+ export const CALLBACK_COMMAND = 'open:callback';
6
+ async function deliver(url, app, trans) {
7
+ try {
8
+ const answer = await requestAPI('callback', app.serviceManager.serverSettings, { method: 'POST', body: JSON.stringify({ url }) });
9
+ return answer.status < 400
10
+ ? {
11
+ level: 'success',
12
+ message: trans.__('The login accepted the link. Check the terminal where the login is running.')
13
+ }
14
+ : {
15
+ level: 'warning',
16
+ message: trans.__('The login did not accept this link. Start the login again and paste the new link.')
17
+ };
18
+ }
19
+ catch (err) {
20
+ const code = err instanceof Error ? err.message : '';
21
+ return {
22
+ level: 'error',
23
+ message: code === 'callback_not_loopback'
24
+ ? trans.__('This link cannot be used. It must start with http://127.0.0.1 or http://localhost.')
25
+ : code === 'callback_unreachable'
26
+ ? trans.__('No login is waiting for this link. Start the login again and paste the new link.')
27
+ : trans.__('The link could not be sent to the Jupyter server. Try again.')
28
+ };
29
+ }
30
+ }
31
+ /** The popup's body: the link field and, below it, what the last send came
32
+ * to. */
33
+ class CallbackForm extends Widget {
34
+ constructor(trans) {
35
+ super();
36
+ this.addClass('jp-AiAssistantsCallback');
37
+ const label = document.createElement('label');
38
+ label.textContent = trans.__('Callback link from the browser address bar');
39
+ this._input = document.createElement('input');
40
+ this._input.type = 'text';
41
+ this._input.className = 'jp-mod-styled';
42
+ this._input.placeholder = 'http://127.0.0.1:1455/auth/callback?code=...';
43
+ label.appendChild(this._input);
44
+ this._status = document.createElement('div');
45
+ this._status.className = 'jp-AiAssistantsCallback-status';
46
+ this._status.setAttribute('role', 'status');
47
+ this.node.append(label, this._status);
48
+ }
49
+ get url() {
50
+ return this._input.value.trim();
51
+ }
52
+ report(level, message) {
53
+ this._status.dataset.level = level;
54
+ this._status.textContent = message;
55
+ }
56
+ }
57
+ /** OK sends the link and leaves the window open, so the result is read - and
58
+ * a refused link replaced - in the same place. Close and Escape end it. */
59
+ class CallbackDialog extends Dialog {
60
+ constructor(_form, _send, _trans) {
61
+ super({
62
+ title: _trans.__('Open Callback'),
63
+ body: _form,
64
+ buttons: [
65
+ Dialog.cancelButton({ label: _trans.__('Close') }),
66
+ Dialog.okButton({ label: _trans.__('OK') })
67
+ ],
68
+ defaultButton: 1,
69
+ focusNodeSelector: 'input'
70
+ });
71
+ this._form = _form;
72
+ this._send = _send;
73
+ this._trans = _trans;
74
+ this._sending = false;
75
+ }
76
+ resolve(index) {
77
+ if (index === 0) {
78
+ super.resolve(index);
79
+ return;
80
+ }
81
+ void this._deliver();
82
+ }
83
+ async _deliver() {
84
+ const url = this._form.url;
85
+ if (this._sending) {
86
+ return;
87
+ }
88
+ if (!url) {
89
+ this._form.report('error', this._trans.__('Paste the link first.'));
90
+ return;
91
+ }
92
+ this._sending = true;
93
+ this._form.report('pending', this._trans.__('Sending the link...'));
94
+ const outcome = await this._send(url);
95
+ this._sending = false;
96
+ if (!this.isDisposed) {
97
+ this._form.report(outcome.level, outcome.message);
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Register the command that sends a pasted login callback link to the login
103
+ * waiting on the Jupyter server.
104
+ *
105
+ * A CLI login waits for its OAuth redirect on the server's own loopback, but
106
+ * the browser that follows the redirect runs on the user's machine, where that
107
+ * address is another host - so the page fails to load, with the link left in
108
+ * the address bar. Pasted here, the server makes the request instead.
109
+ */
110
+ export function addCallbackCommand(app, palette, category, trans) {
111
+ app.commands.addCommand(CALLBACK_COMMAND, {
112
+ label: trans.__('Open Callback'),
113
+ caption: trans.__('Send a pasted login callback link to the login waiting on the Jupyter server'),
114
+ describedBy: {
115
+ args: {
116
+ type: 'object',
117
+ properties: {
118
+ url: {
119
+ type: 'string',
120
+ description: 'The callback link. Asked for in a popup when absent.'
121
+ }
122
+ }
123
+ }
124
+ },
125
+ execute: async (args) => {
126
+ const send = (url) => deliver(url, app, trans);
127
+ const given = typeof args.url === 'string' ? args.url.trim() : '';
128
+ if (given) {
129
+ // No popup to write into, so the result is a toast.
130
+ const outcome = await send(given);
131
+ Notification[outcome.level](outcome.message, {
132
+ autoClose: outcome.level === 'success' ? 4000 : 8000
133
+ });
134
+ return;
135
+ }
136
+ await new CallbackDialog(new CallbackForm(trans), send, trans).launch();
137
+ }
138
+ });
139
+ palette === null || palette === void 0 ? void 0 : palette.addItem({ command: CALLBACK_COMMAND, category });
140
+ }
package/lib/core/icons.js CHANGED
@@ -103,15 +103,16 @@ export const filterIcon = new LabIcon({
103
103
  name: `${ICON_PREFIX}:filter`,
104
104
  svgstr: filterSvgStr
105
105
  });
106
- // Row status mark - one filled circle, drawn for a conversation that is live
107
- // or under remote control. An SVG rather than a rounded span so its colour is
108
- // a CSS `fill` on the circle itself, the way every other glyph here is
109
- // recoloured through its class (DEF-PANE-174). The circle carries no `fill`
110
- // attribute: the stylesheet is the single source of that colour, and a
111
- // presentation attribute here would be a second one to keep in step
112
- // (DEF-PANE-179).
106
+ // Row status mark - a light, drawn for a conversation that is live or under
107
+ // remote control: a 6px circle over the 8px disc that casts its halo. An SVG
108
+ // rather than a rounded span so each colour is a CSS `fill` on the circle
109
+ // itself, the way every other glyph here is recoloured through its class
110
+ // (DEF-PANE-174). The circles carry no `fill` attribute: the stylesheet is the
111
+ // single source of those colours, and a presentation attribute here would be
112
+ // a second one to keep in step (DEF-PANE-179).
113
113
  const statusDotSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
114
- <circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="8"/>
114
+ <circle class="jp-AiAssistantsPanel-dotHalo" cx="8" cy="8" r="8"/>
115
+ <circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="6"/>
115
116
  </svg>`;
116
117
  export const statusDotIcon = new LabIcon({
117
118
  name: `${ICON_PREFIX}:status-dot`,
package/lib/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { ILabShell, ILayoutRestorer } from '@jupyterlab/application';
2
- import { Notification } from '@jupyterlab/apputils';
2
+ import { ICommandPalette, Notification } from '@jupyterlab/apputils';
3
3
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
4
4
  import { ILauncher } from '@jupyterlab/launcher';
5
5
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
6
6
  import { ITerminalTracker } from '@jupyterlab/terminal';
7
7
  import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
8
  import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
9
+ import { addCallbackCommand } from './core/callback';
9
10
  import { launcherTileIcon, providerIcon } from './core/icons';
10
11
  import { DEFAULT_RECENT_LIMIT } from './core/limits';
11
12
  import { AssistantSessionsPanel, DEFAULT_PRESENTATION_MODE, commandId, panelWidgetId } from './core/panel';
@@ -40,9 +41,10 @@ const plugin = {
40
41
  IDefaultFileBrowser,
41
42
  IColourfulTabs,
42
43
  ILauncher,
43
- ITranslator
44
+ ITranslator,
45
+ ICommandPalette
44
46
  ],
45
- activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher, translator) => {
47
+ activate: async (app, labShell, restorer, settingRegistry, terminalTracker, fileBrowser, colourfulTabs, launcher, translator, palette) => {
46
48
  console.log('JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!');
47
49
  // Every user-visible string in the panels goes through this bundle.
48
50
  // `ITranslator` is optional, and the null translator returns each string
@@ -51,6 +53,7 @@ const plugin = {
51
53
  // A duplicate id throws here, at registration, rather than surfacing later
52
54
  // as two panels quietly fighting over one widget id.
53
55
  const registry = new ProviderRegistry(PROVIDERS);
56
+ addCallbackCommand(app, palette !== null && palette !== void 0 ? palette : null, LAUNCHER_CATEGORY, trans);
54
57
  const serverSettings = app.serviceManager.serverSettings;
55
58
  const live = new Map();
56
59
  const warnedUnknown = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.45",
3
+ "version": "1.2.50",
4
4
  "description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The Open Callback command: a pasted login callback link, handed to the
3
+ * server. The server's side - loopback only, one request, nothing logged - is
4
+ * `tests/test_callback.py`; this is the popup, the request and what the user
5
+ * is told.
6
+ */
7
+
8
+ // ESM-only packages that JupyterLab's apputils pulls in and jest cannot parse.
9
+ jest.mock('@jupyter/react-components', () => ({}));
10
+ jest.mock('@jupyter/web-components', () => ({
11
+ addJupyterLabThemeChangeListener: () => undefined,
12
+ applyJupyterTheme: () => undefined,
13
+ jpButton: () => undefined,
14
+ jpToolbar: () => undefined,
15
+ provideJupyterDesignSystem: () => ({ register: () => undefined })
16
+ }));
17
+ jest.mock('../core/request', () => ({
18
+ requestAPI: jest.fn()
19
+ }));
20
+
21
+ import { Notification } from '@jupyterlab/apputils';
22
+ import { nullTranslator } from '@jupyterlab/translation';
23
+ import { CommandRegistry } from '@lumino/commands';
24
+
25
+ import { CALLBACK_COMMAND, addCallbackCommand } from '../core/callback';
26
+ import { requestAPI } from '../core/request';
27
+
28
+ const request = requestAPI as jest.Mock;
29
+ const LINK = 'http://127.0.0.1:1455/auth/callback?code=one-time&state=s';
30
+
31
+ /** One macrotask: `Dialog.launch` attaches the popup after its own awaits. */
32
+ const settle = (): Promise<void> =>
33
+ new Promise(resolve => setTimeout(resolve, 0));
34
+
35
+ function setup(): {
36
+ commands: CommandRegistry;
37
+ added: { command: string; category: string }[];
38
+ serverSettings: object;
39
+ } {
40
+ const commands = new CommandRegistry();
41
+ const serverSettings = {};
42
+ const added: { command: string; category: string }[] = [];
43
+ addCallbackCommand(
44
+ { commands, serviceManager: { serverSettings } } as any,
45
+ { addItem: (item: any) => added.push(item) } as any,
46
+ 'AI Assistants',
47
+ nullTranslator.load('test')
48
+ );
49
+ return { commands, added, serverSettings };
50
+ }
51
+
52
+ interface IPopup {
53
+ /** Settles when the popup has closed. */
54
+ closed: Promise<unknown>;
55
+ input: HTMLInputElement;
56
+ status: HTMLElement;
57
+ ok: HTMLElement;
58
+ close: HTMLElement;
59
+ }
60
+
61
+ async function openPopup(commands: CommandRegistry): Promise<IPopup> {
62
+ const closed = commands.execute(CALLBACK_COMMAND);
63
+ await settle();
64
+ const node = document.querySelector('.jp-Dialog') as HTMLElement;
65
+ return {
66
+ closed,
67
+ input: node.querySelector(
68
+ '.jp-AiAssistantsCallback input'
69
+ ) as HTMLInputElement,
70
+ status: node.querySelector(
71
+ '.jp-AiAssistantsCallback-status'
72
+ ) as HTMLElement,
73
+ ok: node.querySelector('.jp-mod-accept') as HTMLElement,
74
+ close: node.querySelector('.jp-mod-reject') as HTMLElement
75
+ };
76
+ }
77
+
78
+ /** No status code, no server error code and never the link, which carries a
79
+ * login code. */
80
+ function expectPlainWords(text: string): void {
81
+ expect(text).not.toMatch(
82
+ /callback_|HTTP|\b[45]\d\d\b|Internal Server Error|one-time/
83
+ );
84
+ }
85
+
86
+ describe('the Open Callback command', () => {
87
+ let success: jest.SpyInstance;
88
+ let error: jest.SpyInstance;
89
+
90
+ beforeEach(() => {
91
+ request.mockReset();
92
+ success = jest.spyOn(Notification, 'success').mockReturnValue('' as any);
93
+ error = jest.spyOn(Notification, 'error').mockReturnValue('' as any);
94
+ });
95
+
96
+ afterEach(async () => {
97
+ // A popup left open would hold every later one in JupyterLab's queue.
98
+ (
99
+ document.querySelector('.jp-Dialog .jp-mod-reject') as HTMLElement
100
+ )?.click();
101
+ await settle();
102
+ jest.restoreAllMocks();
103
+ });
104
+
105
+ it('is on the command palette under the assistants category', () => {
106
+ const { commands, added } = setup();
107
+ expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
108
+ expect(commands.label(CALLBACK_COMMAND)).toBe('Open Callback');
109
+ expect(added).toEqual([
110
+ { command: CALLBACK_COMMAND, category: 'AI Assistants' }
111
+ ]);
112
+ });
113
+
114
+ it('registers without a palette', () => {
115
+ const commands = new CommandRegistry();
116
+ addCallbackCommand(
117
+ { commands, serviceManager: { serverSettings: {} } } as any,
118
+ null,
119
+ 'AI Assistants',
120
+ nullTranslator.load('test')
121
+ );
122
+ expect(commands.hasCommand(CALLBACK_COMMAND)).toBe(true);
123
+ });
124
+
125
+ it('OK sends the trimmed link and writes the result under the field, window still open', async () => {
126
+ const { commands, serverSettings } = setup();
127
+ request.mockResolvedValue({ status: 302 });
128
+ const popup = await openPopup(commands);
129
+
130
+ popup.input.value = ` ${LINK}\n`;
131
+ popup.ok.click();
132
+ await settle();
133
+
134
+ expect(request).toHaveBeenCalledWith('callback', serverSettings, {
135
+ method: 'POST',
136
+ body: JSON.stringify({ url: LINK })
137
+ });
138
+ expect(popup.status.dataset.level).toBe('success');
139
+ expect(popup.status.textContent).toContain('The login accepted the link');
140
+ expectPlainWords(popup.status.textContent ?? '');
141
+ // Read in the popup, not in a toast, and the popup is still there.
142
+ expect(success).not.toHaveBeenCalled();
143
+ expect(document.querySelector('.jp-Dialog')).not.toBeNull();
144
+
145
+ popup.close.click();
146
+ await popup.closed;
147
+ expect(document.querySelector('.jp-Dialog')).toBeNull();
148
+ });
149
+
150
+ it('Enter in the field sends, as OK does', async () => {
151
+ const { commands } = setup();
152
+ request.mockResolvedValue({ status: 200 });
153
+ const popup = await openPopup(commands);
154
+
155
+ popup.input.value = LINK;
156
+ popup.input.dispatchEvent(
157
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
158
+ );
159
+ await settle();
160
+
161
+ expect(request).toHaveBeenCalledTimes(1);
162
+ expect(popup.status.dataset.level).toBe('success');
163
+ });
164
+
165
+ it('a refused link is replaced and sent again in the same window', async () => {
166
+ const { commands } = setup();
167
+ request
168
+ .mockRejectedValueOnce(new Error('callback_unreachable'))
169
+ .mockResolvedValueOnce({ status: 302 });
170
+ const popup = await openPopup(commands);
171
+
172
+ popup.input.value = LINK;
173
+ popup.ok.click();
174
+ await settle();
175
+ expect(popup.status.dataset.level).toBe('error');
176
+
177
+ popup.input.value = LINK.replace('one-time', 'second');
178
+ popup.ok.click();
179
+ await settle();
180
+ expect(request).toHaveBeenCalledTimes(2);
181
+ expect(popup.status.dataset.level).toBe('success');
182
+ });
183
+
184
+ it('OK on an empty field sends nothing and says so', async () => {
185
+ const { commands } = setup();
186
+ const popup = await openPopup(commands);
187
+
188
+ popup.input.value = ' ';
189
+ popup.ok.click();
190
+ await settle();
191
+
192
+ expect(request).not.toHaveBeenCalled();
193
+ expect(popup.status.textContent).toBe('Paste the link first.');
194
+ });
195
+
196
+ it('Close sends nothing', async () => {
197
+ const { commands } = setup();
198
+ const popup = await openPopup(commands);
199
+
200
+ popup.input.value = LINK;
201
+ popup.close.click();
202
+ await popup.closed;
203
+
204
+ expect(request).not.toHaveBeenCalled();
205
+ expect(document.querySelector('.jp-Dialog')).toBeNull();
206
+ });
207
+
208
+ it.each([
209
+ [
210
+ 'the login answers with an error',
211
+ () => Promise.resolve({ status: 400 }),
212
+ 'warning',
213
+ 'The login did not accept this link'
214
+ ],
215
+ [
216
+ 'the link is not a loopback link',
217
+ () => Promise.reject(new Error('callback_not_loopback')),
218
+ 'error',
219
+ 'It must start with http://127.0.0.1 or http://localhost'
220
+ ],
221
+ [
222
+ 'no login is listening',
223
+ () => Promise.reject(new Error('callback_unreachable')),
224
+ 'error',
225
+ 'No login is waiting for this link'
226
+ ],
227
+ [
228
+ 'the server fails',
229
+ () => Promise.reject(new Error('Internal Server Error')),
230
+ 'error',
231
+ 'The link could not be sent to the Jupyter server'
232
+ ]
233
+ ])('says in plain words that %s', async (_case, answer, level, expected) => {
234
+ const { commands } = setup();
235
+ request.mockImplementation(answer);
236
+ const popup = await openPopup(commands);
237
+
238
+ popup.input.value = LINK;
239
+ popup.ok.click();
240
+ await settle();
241
+
242
+ expect(popup.status.dataset.level).toBe(level);
243
+ expect(popup.status.textContent).toContain(expected);
244
+ expectPlainWords(popup.status.textContent ?? '');
245
+ });
246
+
247
+ it('given the link as an argument, opens no popup and reports in a toast', async () => {
248
+ const { commands } = setup();
249
+ request.mockRejectedValue(new Error('callback_unreachable'));
250
+
251
+ await commands.execute(CALLBACK_COMMAND, { url: LINK });
252
+
253
+ expect(document.querySelector('.jp-Dialog')).toBeNull();
254
+ expect(request).toHaveBeenCalledTimes(1);
255
+ expect(error).toHaveBeenCalledTimes(1);
256
+ expectPlainWords(String(error.mock.calls[0][0]));
257
+ });
258
+ });
@@ -480,6 +480,12 @@ describe('ACC-PROV - the activation contract', () => {
480
480
  ])
481
481
  );
482
482
 
483
+ it('registers the Open Callback command at activation', async () => {
484
+ const live = await activate();
485
+ expect(live.app.commands.hasCommand('open:callback')).toBe(true);
486
+ live.docked.forEach(widget => widget.dispose());
487
+ });
488
+
483
489
  it('ACC-PROV-10, ACC-PROV-11 - each panel carries its own widget id and registers it for restore', async () => {
484
490
  const live = await activate();
485
491
  // How this fails: pass a constant to `restorer.add` and two panels claim
@@ -0,0 +1,189 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
3
+ import { TranslationBundle } from '@jupyterlab/translation';
4
+ import { Widget } from '@lumino/widgets';
5
+
6
+ import { requestAPI } from './request';
7
+
8
+ /** The command that delivers a pasted login callback link. */
9
+ export const CALLBACK_COMMAND = 'open:callback';
10
+
11
+ /** What a delivery came to, in words for the user. Never the link, which
12
+ * carries a login code, and never a status code. */
13
+ interface IOutcome {
14
+ level: 'success' | 'warning' | 'error';
15
+ message: string;
16
+ }
17
+
18
+ async function deliver(
19
+ url: string,
20
+ app: JupyterFrontEnd,
21
+ trans: TranslationBundle
22
+ ): Promise<IOutcome> {
23
+ try {
24
+ const answer = await requestAPI<{ status: number }>(
25
+ 'callback',
26
+ app.serviceManager.serverSettings,
27
+ { method: 'POST', body: JSON.stringify({ url }) }
28
+ );
29
+ return answer.status < 400
30
+ ? {
31
+ level: 'success',
32
+ message: trans.__(
33
+ 'The login accepted the link. Check the terminal where the login is running.'
34
+ )
35
+ }
36
+ : {
37
+ level: 'warning',
38
+ message: trans.__(
39
+ 'The login did not accept this link. Start the login again and paste the new link.'
40
+ )
41
+ };
42
+ } catch (err) {
43
+ const code = err instanceof Error ? err.message : '';
44
+ return {
45
+ level: 'error',
46
+ message:
47
+ code === 'callback_not_loopback'
48
+ ? trans.__(
49
+ 'This link cannot be used. It must start with http://127.0.0.1 or http://localhost.'
50
+ )
51
+ : code === 'callback_unreachable'
52
+ ? trans.__(
53
+ 'No login is waiting for this link. Start the login again and paste the new link.'
54
+ )
55
+ : trans.__(
56
+ 'The link could not be sent to the Jupyter server. Try again.'
57
+ )
58
+ };
59
+ }
60
+ }
61
+
62
+ /** The popup's body: the link field and, below it, what the last send came
63
+ * to. */
64
+ class CallbackForm extends Widget {
65
+ constructor(trans: TranslationBundle) {
66
+ super();
67
+ this.addClass('jp-AiAssistantsCallback');
68
+ const label = document.createElement('label');
69
+ label.textContent = trans.__('Callback link from the browser address bar');
70
+ this._input = document.createElement('input');
71
+ this._input.type = 'text';
72
+ this._input.className = 'jp-mod-styled';
73
+ this._input.placeholder = 'http://127.0.0.1:1455/auth/callback?code=...';
74
+ label.appendChild(this._input);
75
+ this._status = document.createElement('div');
76
+ this._status.className = 'jp-AiAssistantsCallback-status';
77
+ this._status.setAttribute('role', 'status');
78
+ this.node.append(label, this._status);
79
+ }
80
+
81
+ get url(): string {
82
+ return this._input.value.trim();
83
+ }
84
+
85
+ report(level: IOutcome['level'] | 'pending', message: string): void {
86
+ this._status.dataset.level = level;
87
+ this._status.textContent = message;
88
+ }
89
+
90
+ private _input: HTMLInputElement;
91
+ private _status: HTMLDivElement;
92
+ }
93
+
94
+ /** OK sends the link and leaves the window open, so the result is read - and
95
+ * a refused link replaced - in the same place. Close and Escape end it. */
96
+ class CallbackDialog extends Dialog<void> {
97
+ constructor(
98
+ private _form: CallbackForm,
99
+ private _send: (url: string) => Promise<IOutcome>,
100
+ private _trans: TranslationBundle
101
+ ) {
102
+ super({
103
+ title: _trans.__('Open Callback'),
104
+ body: _form,
105
+ buttons: [
106
+ Dialog.cancelButton({ label: _trans.__('Close') }),
107
+ Dialog.okButton({ label: _trans.__('OK') })
108
+ ],
109
+ defaultButton: 1,
110
+ focusNodeSelector: 'input'
111
+ });
112
+ }
113
+
114
+ resolve(index?: number): void {
115
+ if (index === 0) {
116
+ super.resolve(index);
117
+ return;
118
+ }
119
+ void this._deliver();
120
+ }
121
+
122
+ private async _deliver(): Promise<void> {
123
+ const url = this._form.url;
124
+ if (this._sending) {
125
+ return;
126
+ }
127
+ if (!url) {
128
+ this._form.report('error', this._trans.__('Paste the link first.'));
129
+ return;
130
+ }
131
+ this._sending = true;
132
+ this._form.report('pending', this._trans.__('Sending the link...'));
133
+ const outcome = await this._send(url);
134
+ this._sending = false;
135
+ if (!this.isDisposed) {
136
+ this._form.report(outcome.level, outcome.message);
137
+ }
138
+ }
139
+
140
+ private _sending = false;
141
+ }
142
+
143
+ /**
144
+ * Register the command that sends a pasted login callback link to the login
145
+ * waiting on the Jupyter server.
146
+ *
147
+ * A CLI login waits for its OAuth redirect on the server's own loopback, but
148
+ * the browser that follows the redirect runs on the user's machine, where that
149
+ * address is another host - so the page fails to load, with the link left in
150
+ * the address bar. Pasted here, the server makes the request instead.
151
+ */
152
+ export function addCallbackCommand(
153
+ app: JupyterFrontEnd,
154
+ palette: ICommandPalette | null,
155
+ category: string,
156
+ trans: TranslationBundle
157
+ ): void {
158
+ app.commands.addCommand(CALLBACK_COMMAND, {
159
+ label: trans.__('Open Callback'),
160
+ caption: trans.__(
161
+ 'Send a pasted login callback link to the login waiting on the Jupyter server'
162
+ ),
163
+ describedBy: {
164
+ args: {
165
+ type: 'object',
166
+ properties: {
167
+ url: {
168
+ type: 'string',
169
+ description: 'The callback link. Asked for in a popup when absent.'
170
+ }
171
+ }
172
+ }
173
+ },
174
+ execute: async args => {
175
+ const send = (url: string): Promise<IOutcome> => deliver(url, app, trans);
176
+ const given = typeof args.url === 'string' ? args.url.trim() : '';
177
+ if (given) {
178
+ // No popup to write into, so the result is a toast.
179
+ const outcome = await send(given);
180
+ Notification[outcome.level](outcome.message, {
181
+ autoClose: outcome.level === 'success' ? 4000 : 8000
182
+ });
183
+ return;
184
+ }
185
+ await new CallbackDialog(new CallbackForm(trans), send, trans).launch();
186
+ }
187
+ });
188
+ palette?.addItem({ command: CALLBACK_COMMAND, category });
189
+ }
package/src/core/icons.ts CHANGED
@@ -126,15 +126,16 @@ export const filterIcon = new LabIcon({
126
126
  svgstr: filterSvgStr
127
127
  });
128
128
 
129
- // Row status mark - one filled circle, drawn for a conversation that is live
130
- // or under remote control. An SVG rather than a rounded span so its colour is
131
- // a CSS `fill` on the circle itself, the way every other glyph here is
132
- // recoloured through its class (DEF-PANE-174). The circle carries no `fill`
133
- // attribute: the stylesheet is the single source of that colour, and a
134
- // presentation attribute here would be a second one to keep in step
135
- // (DEF-PANE-179).
129
+ // Row status mark - a light, drawn for a conversation that is live or under
130
+ // remote control: a 6px circle over the 8px disc that casts its halo. An SVG
131
+ // rather than a rounded span so each colour is a CSS `fill` on the circle
132
+ // itself, the way every other glyph here is recoloured through its class
133
+ // (DEF-PANE-174). The circles carry no `fill` attribute: the stylesheet is the
134
+ // single source of those colours, and a presentation attribute here would be
135
+ // a second one to keep in step (DEF-PANE-179).
136
136
  const statusDotSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
137
- <circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="8"/>
137
+ <circle class="jp-AiAssistantsPanel-dotHalo" cx="8" cy="8" r="8"/>
138
+ <circle class="jp-AiAssistantsPanel-dotGlyph" cx="8" cy="8" r="6"/>
138
139
  </svg>`;
139
140
 
140
141
  export const statusDotIcon = new LabIcon({
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  JupyterFrontEnd,
5
5
  JupyterFrontEndPlugin
6
6
  } from '@jupyterlab/application';
7
- import { Notification } from '@jupyterlab/apputils';
7
+ import { ICommandPalette, Notification } from '@jupyterlab/apputils';
8
8
  import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
9
9
  import { ILauncher } from '@jupyterlab/launcher';
10
10
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
@@ -13,6 +13,7 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
13
13
  import { IColourfulTabs } from 'jupyterlab_colourful_tab_extension';
14
14
  import { IDisposable } from '@lumino/disposable';
15
15
 
16
+ import { addCallbackCommand } from './core/callback';
16
17
  import { launcherTileIcon, providerIcon } from './core/icons';
17
18
  import { DEFAULT_RECENT_LIMIT } from './core/limits';
18
19
  import {
@@ -75,7 +76,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
75
76
  IDefaultFileBrowser,
76
77
  IColourfulTabs,
77
78
  ILauncher,
78
- ITranslator
79
+ ITranslator,
80
+ ICommandPalette
79
81
  ],
80
82
  activate: async (
81
83
  app: JupyterFrontEnd,
@@ -86,7 +88,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
86
88
  fileBrowser: IDefaultFileBrowser | null,
87
89
  colourfulTabs: IColourfulTabs | null,
88
90
  launcher: ILauncher | null,
89
- translator: ITranslator | null
91
+ translator: ITranslator | null,
92
+ palette: ICommandPalette | null
90
93
  ) => {
91
94
  console.log(
92
95
  'JupyterLab extension jupyterlab_ai_code_assistants_extension is activated!'
@@ -103,6 +106,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
103
106
  // as two panels quietly fighting over one widget id.
104
107
  const registry = new ProviderRegistry(PROVIDERS);
105
108
 
109
+ addCallbackCommand(app, palette ?? null, LAUNCHER_CATEGORY, trans);
110
+
106
111
  const serverSettings = app.serviceManager.serverSettings;
107
112
  const live = new Map<string, ILivePanel>();
108
113
  const warnedUnknown = new Set<string>();
package/style/base.css CHANGED
@@ -394,6 +394,15 @@
394
394
  fill: var(--jp-success-color1);
395
395
  }
396
396
 
397
+ /* The 8px disc behind the 6px light. It is what casts the halo, so the halo
398
+ keeps the size it had when the light itself was 8px; fully opaque for that
399
+ reason, since a shadow is cast by alpha. The 1px of it that shows around the
400
+ light is painted as the halo looks at that point - the glow colour, dimmed -
401
+ so it reads as the halo's inner edge and not as a second dot. */
402
+ .jp-AiAssistantsPanel-dotHalo {
403
+ fill: color-mix(in srgb, var(--jp-success-color1) 65%, black);
404
+ }
405
+
397
406
  /* Light themes take a different dot, because both halves of the one above stop
398
407
  working there. JupyterLab stamps the theme's own `isLight` onto the body, so
399
408
  the switch is CSS and no TypeScript sees it.
@@ -426,6 +435,12 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotGlyph {
426
435
  fill: var(--md-green-500);
427
436
  }
428
437
 
438
+ /* The mint ring's own colour: three passes saturate the ring at the disc's
439
+ edge, so undimmed mint is what the halo looks like there. */
440
+ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
441
+ fill: var(--md-green-200);
442
+ }
443
+
429
444
  .jp-AiAssistantsPanel-dotPlaceholder {
430
445
  background-color: transparent;
431
446
  }
@@ -1001,3 +1016,40 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotGlyph {
1001
1016
  .jp-AiAssistantsPanel-row.jp-mod-stale .jp-AiAssistantsPanel-name {
1002
1017
  opacity: 0.65;
1003
1018
  }
1019
+
1020
+ /* The Open Callback popup. A callback link runs to about 250 characters, so
1021
+ the field takes the width a normal window can give it. */
1022
+ .jp-AiAssistantsCallback {
1023
+ width: min(640px, 80vw);
1024
+ }
1025
+
1026
+ .jp-AiAssistantsCallback label {
1027
+ display: flex;
1028
+ flex-direction: column;
1029
+ gap: 4px;
1030
+ }
1031
+
1032
+ .jp-AiAssistantsCallback input {
1033
+ width: 100%;
1034
+ box-sizing: border-box;
1035
+ }
1036
+
1037
+ /* What the last send came to. The height is reserved, so the buttons do not
1038
+ move when the text arrives. */
1039
+ .jp-AiAssistantsCallback-status {
1040
+ min-height: 1.5em;
1041
+ margin-top: 8px;
1042
+ color: var(--jp-ui-font-color2);
1043
+ }
1044
+
1045
+ .jp-AiAssistantsCallback-status[data-level='success'] {
1046
+ color: var(--jp-success-color1);
1047
+ }
1048
+
1049
+ .jp-AiAssistantsCallback-status[data-level='warning'] {
1050
+ color: var(--jp-warn-color1);
1051
+ }
1052
+
1053
+ .jp-AiAssistantsCallback-status[data-level='error'] {
1054
+ color: var(--jp-error-color1);
1055
+ }